Change project organization
This commit is contained in:
240
core/big_number.gd
Normal file
240
core/big_number.gd
Normal file
@@ -0,0 +1,240 @@
|
||||
class_name BigNumber
|
||||
extends RefCounted
|
||||
|
||||
## The core value representation.
|
||||
## For example, 1.5e24 is represented as mantissa = 1.5, exponent = 24.
|
||||
var mantissa: float
|
||||
var exponent: int
|
||||
|
||||
## Pre-calculate this to avoid dividing logs constantly
|
||||
const LOG10_E: float = 0.4342944819032518
|
||||
|
||||
func _init(m: float = 0.0, e: int = 0) -> void:
|
||||
mantissa = m
|
||||
exponent = e
|
||||
_normalize()
|
||||
|
||||
## Adjusts the internal representation so the mantissa is always between 1.0 and 9.99...
|
||||
## or exactly 0.0.
|
||||
func _normalize() -> void:
|
||||
if mantissa == 0.0:
|
||||
exponent = 0
|
||||
return
|
||||
|
||||
var is_negative: bool = mantissa < 0.0
|
||||
var abs_m: float = abs(mantissa)
|
||||
|
||||
if abs_m >= 10.0 or abs_m < 1.0:
|
||||
# Calculate how many powers of 10 we need to shift
|
||||
# Godot's log() is base 'e', so we multiply by log10(e) to get log10(x)
|
||||
var exp_diff: int = floori(log(abs_m) * LOG10_E)
|
||||
abs_m /= pow(10.0, float(exp_diff))
|
||||
exponent += exp_diff
|
||||
|
||||
mantissa = abs_m if not is_negative else -abs_m
|
||||
|
||||
# ==========================================
|
||||
# MATH OPERATIONS
|
||||
# ==========================================
|
||||
|
||||
func add(other: BigNumber) -> BigNumber:
|
||||
if mantissa == 0: return BigNumber.new(other.mantissa, other.exponent)
|
||||
if other.mantissa == 0: return BigNumber.new(mantissa, exponent)
|
||||
|
||||
var exp_diff: int = exponent - other.exponent
|
||||
|
||||
# If the difference in magnitude is massive, the smaller number is insignificant
|
||||
# (15 is a safe threshold for 64-bit float precision).
|
||||
if exp_diff >= 15:
|
||||
return BigNumber.new(mantissa, exponent)
|
||||
elif exp_diff <= -15:
|
||||
return BigNumber.new(other.mantissa, other.exponent)
|
||||
|
||||
var new_m: float = mantissa
|
||||
var new_e: int = exponent
|
||||
|
||||
# Scale the smaller number down to match the larger number's exponent
|
||||
if exp_diff > 0:
|
||||
new_m += other.mantissa / pow(10.0, float(exp_diff))
|
||||
elif exp_diff < 0:
|
||||
new_m = (mantissa / pow(10.0, float(-exp_diff))) + other.mantissa
|
||||
new_e = other.exponent
|
||||
else:
|
||||
new_m += other.mantissa
|
||||
|
||||
return BigNumber.new(new_m, new_e)
|
||||
|
||||
## High-performance addition for the _process() loop.
|
||||
## Modifies THIS instance instead of creating a new RefCounted object.
|
||||
func add_in_place(other: BigNumber) -> void:
|
||||
if other.mantissa == 0.0: return
|
||||
if mantissa == 0.0:
|
||||
mantissa = other.mantissa
|
||||
exponent = other.exponent
|
||||
return
|
||||
|
||||
var exp_diff: int = exponent - other.exponent
|
||||
|
||||
if exp_diff >= 15:
|
||||
return # Other number is too small to matter
|
||||
elif exp_diff <= -15:
|
||||
# This number is effectively replaced by the larger other number
|
||||
mantissa = other.mantissa
|
||||
exponent = other.exponent
|
||||
return
|
||||
|
||||
if exp_diff > 0:
|
||||
mantissa += other.mantissa / pow(10.0, float(exp_diff))
|
||||
elif exp_diff < 0:
|
||||
mantissa = (mantissa / pow(10.0, float(-exp_diff))) + other.mantissa
|
||||
exponent = other.exponent
|
||||
else:
|
||||
mantissa += other.mantissa
|
||||
|
||||
# Prevent floating-point drift near zero
|
||||
if abs(mantissa) < 0.0000000001:
|
||||
mantissa = 0.0
|
||||
exponent = 0
|
||||
else:
|
||||
_normalize()
|
||||
|
||||
func subtract(other: BigNumber) -> BigNumber:
|
||||
# Subtraction is just adding a negative number
|
||||
var negative_other = BigNumber.new(-other.mantissa, other.exponent)
|
||||
return add(negative_other)
|
||||
|
||||
func multiply(other: BigNumber) -> BigNumber:
|
||||
var new_m: float = mantissa * other.mantissa
|
||||
var new_e: int = exponent + other.exponent
|
||||
return BigNumber.new(new_m, new_e)
|
||||
|
||||
func divide(other: BigNumber) -> BigNumber:
|
||||
if other.mantissa == 0.0:
|
||||
push_error("BigNumber: Division by zero!")
|
||||
return BigNumber.new(0.0, 0)
|
||||
|
||||
var new_m: float = mantissa / other.mantissa
|
||||
var new_e: int = exponent - other.exponent
|
||||
return BigNumber.new(new_m, new_e)
|
||||
|
||||
# ==========================================
|
||||
# COMPARISONS
|
||||
# ==========================================
|
||||
|
||||
## Returns 1 if this > other, -1 if this < other, 0 if equal
|
||||
func compare_to(other: BigNumber) -> int:
|
||||
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
|
||||
if mantissa > 0 and other.mantissa < 0:
|
||||
return 1
|
||||
if mantissa < 0 and other.mantissa > 0:
|
||||
return -1
|
||||
|
||||
# Both are same sign. Compare exponents first.
|
||||
var sign_mult: int = 1 if mantissa > 0 else -1
|
||||
|
||||
if exponent > other.exponent:
|
||||
return sign_mult
|
||||
if exponent < other.exponent:
|
||||
return -sign_mult
|
||||
|
||||
# Exponents are equal, compare mantissas
|
||||
if mantissa > other.mantissa:
|
||||
return 1
|
||||
if mantissa < other.mantissa:
|
||||
return -1
|
||||
|
||||
return 0
|
||||
|
||||
func is_greater_than(other: BigNumber) -> bool:
|
||||
return compare_to(other) == 1
|
||||
|
||||
func is_less_than(other: BigNumber) -> bool:
|
||||
return compare_to(other) == -1
|
||||
|
||||
func is_equal_to(other: BigNumber) -> bool:
|
||||
return compare_to(other) == 0
|
||||
|
||||
## Calculates the progress ratio between this number and a target number.
|
||||
## Returns a standard float clamped between 0.0 and 1.0 for UI progress bars.
|
||||
func get_ratio(target: BigNumber) -> float:
|
||||
if target.mantissa == 0.0:
|
||||
return 1.0 # If the goal is 0, you've already beaten it!
|
||||
if mantissa == 0.0:
|
||||
return 0.0
|
||||
|
||||
var exp_diff: int = exponent - target.exponent
|
||||
|
||||
# If the target is massively larger, progress is practically 0%
|
||||
if exp_diff <= -15:
|
||||
return 0.0
|
||||
|
||||
# If current is equal or greater, progress is 100%
|
||||
if exp_diff >= 15 or is_greater_than(target) or is_equal_to(target):
|
||||
return 1.0
|
||||
|
||||
# Calculate the actual float ratio
|
||||
var ratio: float = (mantissa / target.mantissa) * pow(10.0, float(exp_diff))
|
||||
|
||||
# Clamp it just to be perfectly safe for UI elements
|
||||
return clampf(ratio, 0.0, 1.0)
|
||||
|
||||
# ==========================================
|
||||
# UTILITIES
|
||||
# ==========================================
|
||||
|
||||
## Creates a BigNumber from a standard float or int
|
||||
static func from_float(val: float) -> BigNumber:
|
||||
return BigNumber.new(val, 0)
|
||||
|
||||
## Outputs a UI-friendly string (e.g., "1.50e12")
|
||||
func to_string_sci(decimals: int = 2) -> String:
|
||||
if exponent < 3:
|
||||
# For small numbers, just show the regular number
|
||||
var val: float = mantissa * pow(10.0, float(exponent))
|
||||
return ("%." + str(decimals) + "f") % val
|
||||
|
||||
var format_str: String = "%." + str(decimals) + "f"
|
||||
return (format_str % mantissa) + "e" + str(exponent)
|
||||
|
||||
## Optional: Standard idle game suffix formatting (K, M, B, T, Qa, etc.)
|
||||
func to_string_suffix(decimals: int = 2) -> String:
|
||||
if exponent < 3:
|
||||
return to_string_sci(decimals)
|
||||
|
||||
var suffixes = ["", "K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc"]
|
||||
var suffix_index: int = floori(exponent / 3.0)
|
||||
|
||||
if suffix_index < suffixes.size():
|
||||
var display_mantissa = mantissa * pow(10.0, float(exponent % 3))
|
||||
var format_str: String = "%." + str(decimals) + "f"
|
||||
return (format_str % display_mantissa) + suffixes[suffix_index]
|
||||
else:
|
||||
# Fall back to scientific if we run out of suffixes
|
||||
return to_string_sci(decimals)
|
||||
|
||||
# ==========================================
|
||||
# SAVE & LOAD (SERIALIZATION)
|
||||
# ==========================================
|
||||
|
||||
## Converts the BigNumber into a basic Dictionary for easy JSON saving.
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"m": mantissa,
|
||||
"e": exponent
|
||||
}
|
||||
|
||||
## A static factory method that creates a new BigNumber from loaded Dictionary data.
|
||||
static func deserialize(data: Dictionary) -> BigNumber:
|
||||
# Provide fallbacks (0.0 and 0) just in case the save file is corrupted
|
||||
var loaded_m: float = data.get("m", 0.0)
|
||||
var loaded_e: int = data.get("e", 0)
|
||||
return BigNumber.new(loaded_m, loaded_e)
|
||||
1
core/big_number.gd.uid
Normal file
1
core/big_number.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://be4in86mvsj0q
|
||||
6
core/currency/currency.gd
Normal file
6
core/currency/currency.gd
Normal file
@@ -0,0 +1,6 @@
|
||||
class_name Currency
|
||||
extends Resource
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var display_name: String = ""
|
||||
@export var icon: Texture2D
|
||||
1
core/currency/currency.gd.uid
Normal file
1
core/currency/currency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dtgqjf3bl7pm8
|
||||
164
core/currency_database.gd
Normal file
164
core/currency_database.gd
Normal file
@@ -0,0 +1,164 @@
|
||||
extends Node
|
||||
|
||||
#const GOLD_CURRENCY_ID: StringName = &"gold"
|
||||
#const GEMS_CURRENCY_ID: StringName = &"gems"
|
||||
#const LEGACY_CURRENCY_INDEX_TO_ID: Dictionary = {
|
||||
#0: GOLD_CURRENCY_ID,
|
||||
#1: GEMS_CURRENCY_ID,
|
||||
#}
|
||||
|
||||
const CURRENCY_DIRECTORY_PATH: String = "res://idles/currencies"
|
||||
const CURRENCY_RESOURCE_EXTENSIONS: PackedStringArray = ["tres", "res"]
|
||||
|
||||
var _currency_by_id: Dictionary = {}
|
||||
var _catalog_initialized: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_initialize_currency_catalog()
|
||||
|
||||
func get_known_currencies() -> Array[Resource]:
|
||||
_initialize_currency_catalog_if_needed()
|
||||
|
||||
var result: Array[Resource] = []
|
||||
for raw_currency in _currency_by_id.values():
|
||||
var currency: Resource = raw_currency as Resource
|
||||
if currency != null:
|
||||
result.append(currency)
|
||||
return result
|
||||
|
||||
func get_known_currency_ids() -> Array[StringName]:
|
||||
_initialize_currency_catalog_if_needed()
|
||||
|
||||
var ids: Array[StringName] = []
|
||||
for raw_currency_id in _currency_by_id.keys():
|
||||
var currency_id: StringName = _normalize_currency_id(StringName(String(raw_currency_id)))
|
||||
if currency_id == &"":
|
||||
continue
|
||||
ids.append(currency_id)
|
||||
return ids
|
||||
|
||||
func get_currency_id(currency: Resource) -> StringName:
|
||||
if currency == null:
|
||||
return &""
|
||||
|
||||
var raw_id: Variant = currency.get("id")
|
||||
if raw_id is StringName:
|
||||
return _normalize_currency_id(raw_id)
|
||||
return _normalize_currency_id(StringName(String(raw_id)))
|
||||
|
||||
func parse_currency_id(raw_currency: Variant) -> StringName:
|
||||
#if raw_currency is int:
|
||||
#return currency_id_from_legacy_index(raw_currency)
|
||||
|
||||
var currency_text: String = String(raw_currency).to_lower().strip_edges()
|
||||
if currency_text == "gem":
|
||||
currency_text = "gems"
|
||||
return _normalize_currency_id(StringName(currency_text))
|
||||
|
||||
#func currency_id_from_legacy_index(currency_index: int) -> StringName:
|
||||
#return LEGACY_CURRENCY_INDEX_TO_ID.get(currency_index, &"")
|
||||
|
||||
func is_known_currency_id(currency_id: StringName) -> bool:
|
||||
_initialize_currency_catalog_if_needed()
|
||||
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return false
|
||||
return _currency_by_id.has(normalized_currency_id)
|
||||
|
||||
func get_currency_resource(currency_id: StringName) -> Resource:
|
||||
_initialize_currency_catalog_if_needed()
|
||||
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return null
|
||||
|
||||
var raw_currency: Variant = _currency_by_id.get(normalized_currency_id)
|
||||
if raw_currency is Resource:
|
||||
return raw_currency
|
||||
return null
|
||||
|
||||
func get_currency_name(currency_id: StringName) -> String:
|
||||
var currency: Resource = get_currency_resource(currency_id)
|
||||
if currency != null:
|
||||
var display_name: String = String(currency.get("display_name")).strip_edges()
|
||||
if not display_name.is_empty():
|
||||
return display_name
|
||||
return _humanize_currency_id(currency_id)
|
||||
|
||||
func get_currency_icon(currency_id: StringName) -> Texture2D:
|
||||
var currency: Resource = get_currency_resource(currency_id)
|
||||
if currency == null:
|
||||
return null
|
||||
|
||||
var raw_icon: Variant = currency.get("icon")
|
||||
if raw_icon is Texture2D:
|
||||
return raw_icon
|
||||
return null
|
||||
|
||||
func _initialize_currency_catalog_if_needed() -> void:
|
||||
if _catalog_initialized:
|
||||
return
|
||||
_initialize_currency_catalog()
|
||||
|
||||
func _initialize_currency_catalog() -> void:
|
||||
_currency_by_id.clear()
|
||||
_catalog_initialized = true
|
||||
|
||||
var currency_resource_paths: PackedStringArray = _discover_currency_resource_paths()
|
||||
for resource_path in currency_resource_paths:
|
||||
var loaded_resource: Resource = load(resource_path)
|
||||
if loaded_resource == null:
|
||||
push_warning("Failed to load currency resource at '%s'." % resource_path)
|
||||
continue
|
||||
if not (loaded_resource is Currency):
|
||||
continue
|
||||
|
||||
var currency_id: StringName = get_currency_id(loaded_resource)
|
||||
if currency_id == &"":
|
||||
push_warning("Skipping currency with missing id: %s" % resource_path)
|
||||
continue
|
||||
if _currency_by_id.has(currency_id):
|
||||
push_warning("Duplicate currency id '%s' found at %s. Keeping first occurrence." % [String(currency_id), resource_path])
|
||||
continue
|
||||
|
||||
_currency_by_id[currency_id] = loaded_resource
|
||||
|
||||
if _currency_by_id.is_empty():
|
||||
push_warning("No currency resources discovered under %s. Ensure currency .tres files exist and are included in export presets." % CURRENCY_DIRECTORY_PATH)
|
||||
|
||||
func _discover_currency_resource_paths() -> PackedStringArray:
|
||||
var result: PackedStringArray = []
|
||||
var directory: DirAccess = DirAccess.open(CURRENCY_DIRECTORY_PATH)
|
||||
if directory == null:
|
||||
push_warning("Unable to open currency directory: %s" % CURRENCY_DIRECTORY_PATH)
|
||||
return result
|
||||
|
||||
directory.list_dir_begin()
|
||||
var entry_name: String = directory.get_next()
|
||||
while not entry_name.is_empty():
|
||||
if directory.current_is_dir() or entry_name.begins_with("."):
|
||||
entry_name = directory.get_next()
|
||||
continue
|
||||
|
||||
var extension: String = entry_name.get_extension().to_lower()
|
||||
if CURRENCY_RESOURCE_EXTENSIONS.has(extension):
|
||||
result.append("%s/%s" % [CURRENCY_DIRECTORY_PATH, entry_name])
|
||||
|
||||
entry_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
func _normalize_currency_id(currency_id: StringName) -> StringName:
|
||||
var normalized: String = String(currency_id).to_lower().strip_edges()
|
||||
if normalized.is_empty():
|
||||
return &""
|
||||
return StringName(normalized)
|
||||
|
||||
func _humanize_currency_id(currency_id: StringName) -> String:
|
||||
var normalized: String = String(_normalize_currency_id(currency_id))
|
||||
if normalized.is_empty():
|
||||
return "Unknown"
|
||||
return normalized.replace("_", " ").capitalize()
|
||||
1
core/currency_database.gd.uid
Normal file
1
core/currency_database.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dmv83fnq26xao
|
||||
569
core/game_state.gd
Normal file
569
core/game_state.gd
Normal file
@@ -0,0 +1,569 @@
|
||||
extends Node
|
||||
# This script is added as an Autoload named 'GameState'
|
||||
|
||||
# ==========================================
|
||||
# SIGNALS
|
||||
# ==========================================
|
||||
signal currency_changed(currency_id: StringName, new_amount: BigNumber)
|
||||
signal generator_state_changed(generator_id: StringName, state: Dictionary)
|
||||
signal generator_buff_level_changed(generator_id: StringName, buff_id: StringName, new_level: int)
|
||||
|
||||
# ==========================================
|
||||
# STATE VARIABLES
|
||||
# ==========================================
|
||||
var _current_currency: Dictionary = {}
|
||||
var _total_currency_acquired: Dictionary = {}
|
||||
|
||||
var generator_states: Dictionary = {}
|
||||
var generator_buff_levels: Dictionary = {}
|
||||
|
||||
var last_save_time: int = 0 # Unix timestamp
|
||||
|
||||
# ==========================================
|
||||
# Save / Load
|
||||
# ==========================================
|
||||
static var file_name: String = "user://idle_save.json"
|
||||
const GENERATOR_OWNED_KEY: String = "owned"
|
||||
const GENERATOR_PURCHASED_COUNT_KEY: String = "purchased_count"
|
||||
const GENERATOR_UNLOCKED_KEY: String = "unlocked"
|
||||
const GENERATOR_AVAILABLE_KEY: String = "available"
|
||||
const GENERATOR_STATES_KEY: String = "generator_states"
|
||||
const GENERATOR_BUFF_LEVELS_KEY: String = "generator_buff_levels"
|
||||
const CURRENCIES_KEY: String = "currencies"
|
||||
const CURRENT_KEY: String = "current"
|
||||
const TOTAL_KEY: String = "total"
|
||||
const LAST_SAVE_TIME_KEY: String = "last_save_time"
|
||||
|
||||
func _ready() -> void:
|
||||
_initialize_currency_maps()
|
||||
load_game()
|
||||
|
||||
# ==========================================
|
||||
# STATE MODIFIERS
|
||||
# ==========================================
|
||||
#func add_gold(amount: BigNumber) -> void:
|
||||
#add_currency_by_id(GOLD_CURRENCY_ID, amount)
|
||||
#
|
||||
#func spend_gold(cost: BigNumber) -> bool:
|
||||
#return spend_currency_by_id(GOLD_CURRENCY_ID, cost)
|
||||
#
|
||||
#func add_gems(amount: BigNumber) -> void:
|
||||
#add_currency_by_id(GEMS_CURRENCY_ID, amount)
|
||||
#
|
||||
#func spend_gems(cost: BigNumber) -> bool:
|
||||
#return spend_currency_by_id(GEMS_CURRENCY_ID, cost)
|
||||
|
||||
func get_known_currencies() -> Array[Resource]:
|
||||
return CurrencyDatabase.get_known_currencies()
|
||||
|
||||
func get_currency_id(currency: Resource) -> StringName:
|
||||
return CurrencyDatabase.get_currency_id(currency)
|
||||
|
||||
func parse_currency_id(raw_currency: Variant) -> StringName:
|
||||
return CurrencyDatabase.parse_currency_id(raw_currency)
|
||||
|
||||
func is_known_currency_id(currency_id: StringName) -> bool:
|
||||
return CurrencyDatabase.is_known_currency_id(currency_id)
|
||||
|
||||
func get_currency_resource(currency_id: StringName) -> Resource:
|
||||
return CurrencyDatabase.get_currency_resource(currency_id)
|
||||
|
||||
func get_currency_name(currency_id: StringName) -> String:
|
||||
return CurrencyDatabase.get_currency_name(currency_id)
|
||||
|
||||
func get_currency_icon(currency_id: StringName) -> Texture2D:
|
||||
return CurrencyDatabase.get_currency_icon(currency_id)
|
||||
|
||||
func add_currency(currency: Resource, amount: BigNumber) -> void:
|
||||
var currency_id: StringName = get_currency_id(currency)
|
||||
if currency_id == &"":
|
||||
push_warning("Attempted to add currency with an invalid Currency resource.")
|
||||
return
|
||||
|
||||
add_currency_by_id(currency_id, amount)
|
||||
|
||||
func spend_currency(currency: Resource, cost: BigNumber) -> bool:
|
||||
var currency_id: StringName = get_currency_id(currency)
|
||||
if currency_id == &"":
|
||||
push_warning("Attempted to spend currency with an invalid Currency resource.")
|
||||
return false
|
||||
|
||||
return spend_currency_by_id(currency_id, cost)
|
||||
|
||||
func add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void:
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return
|
||||
|
||||
if amount.mantissa > 0.0:
|
||||
_get_total_currency_ref(normalized_currency_id).add_in_place(amount)
|
||||
|
||||
var current: BigNumber = _get_current_currency_ref(normalized_currency_id)
|
||||
current.add_in_place(amount)
|
||||
currency_changed.emit(normalized_currency_id, current)
|
||||
|
||||
func spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool:
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return false
|
||||
|
||||
var current: BigNumber = _get_current_currency_ref(normalized_currency_id)
|
||||
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(normalized_currency_id, current)
|
||||
return true
|
||||
return false
|
||||
|
||||
func get_currency_amount(currency: Resource) -> BigNumber:
|
||||
return get_currency_amount_by_id(get_currency_id(currency))
|
||||
|
||||
func get_currency_amount_by_id(currency_id: StringName) -> BigNumber:
|
||||
return _get_current_currency_ref(currency_id)
|
||||
|
||||
func get_total_currency_acquired(currency: Resource) -> BigNumber:
|
||||
return get_total_currency_acquired_by_id(get_currency_id(currency))
|
||||
|
||||
func get_total_currency_acquired_by_id(currency_id: StringName) -> BigNumber:
|
||||
return _get_total_currency_ref(currency_id)
|
||||
|
||||
func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false) -> void:
|
||||
var key: String = String(generator_id)
|
||||
if key.is_empty():
|
||||
return
|
||||
|
||||
if generator_states.has(key):
|
||||
var existing_raw: Variant = generator_states.get(key)
|
||||
if existing_raw is Dictionary:
|
||||
generator_states[key] = _sanitize_generator_state(existing_raw, unlocked, available)
|
||||
return
|
||||
|
||||
var new_state: Dictionary = _default_generator_state(unlocked, available)
|
||||
generator_states[key] = new_state
|
||||
generator_state_changed.emit(StringName(key), new_state.duplicate(true))
|
||||
|
||||
func get_generator_state(generator_id: StringName) -> Dictionary:
|
||||
return _get_generator_state(generator_id).duplicate(true)
|
||||
|
||||
func get_generator_owned(generator_id: StringName) -> int:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
return int(state.get(GENERATOR_OWNED_KEY, 0))
|
||||
|
||||
func set_generator_owned(generator_id: StringName, value: int) -> void:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
var next_owned: int = maxi(value, 0)
|
||||
if int(state.get(GENERATOR_OWNED_KEY, 0)) == next_owned:
|
||||
return
|
||||
|
||||
state[GENERATOR_OWNED_KEY] = next_owned
|
||||
state[GENERATOR_PURCHASED_COUNT_KEY] = maxi(int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)), next_owned)
|
||||
_set_generator_state(generator_id, state)
|
||||
|
||||
func get_generator_purchased_count(generator_id: StringName) -> int:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
return int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0))
|
||||
|
||||
func set_generator_purchased_count(generator_id: StringName, value: int) -> void:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
var min_purchased: int = int(state.get(GENERATOR_OWNED_KEY, 0))
|
||||
var next_purchased: int = maxi(value, min_purchased)
|
||||
if int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) == next_purchased:
|
||||
return
|
||||
|
||||
state[GENERATOR_PURCHASED_COUNT_KEY] = next_purchased
|
||||
_set_generator_state(generator_id, state)
|
||||
|
||||
func is_generator_unlocked(generator_id: StringName) -> bool:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
return bool(state.get(GENERATOR_UNLOCKED_KEY, false))
|
||||
|
||||
func set_generator_unlocked(generator_id: StringName, value: bool) -> void:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
if bool(state.get(GENERATOR_UNLOCKED_KEY, false)) == value:
|
||||
return
|
||||
|
||||
state[GENERATOR_UNLOCKED_KEY] = value
|
||||
_set_generator_state(generator_id, state)
|
||||
|
||||
func is_generator_available(generator_id: StringName) -> bool:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
return bool(state.get(GENERATOR_AVAILABLE_KEY, false))
|
||||
|
||||
func set_generator_available(generator_id: StringName, value: bool) -> void:
|
||||
var state: Dictionary = _get_generator_state(generator_id)
|
||||
if bool(state.get(GENERATOR_AVAILABLE_KEY, false)) == value:
|
||||
return
|
||||
|
||||
state[GENERATOR_AVAILABLE_KEY] = value
|
||||
_set_generator_state(generator_id, state)
|
||||
|
||||
func register_generator_buff(generator_id: StringName, buff_id: StringName, initial_level: int = 0) -> void:
|
||||
var generator_key: String = String(generator_id)
|
||||
var buff_key: String = String(buff_id)
|
||||
if generator_key.is_empty() or buff_key.is_empty():
|
||||
return
|
||||
|
||||
var levels: Dictionary = _get_generator_buff_levels_ref(generator_id)
|
||||
if levels.has(buff_key):
|
||||
var existing_level: int = _to_non_negative_int(levels.get(buff_key, initial_level), initial_level)
|
||||
if int(levels.get(buff_key, initial_level)) != existing_level:
|
||||
levels[buff_key] = existing_level
|
||||
generator_buff_levels[generator_key] = levels
|
||||
generator_buff_level_changed.emit(StringName(generator_key), StringName(buff_key), existing_level)
|
||||
return
|
||||
|
||||
var sanitized_level: int = _to_non_negative_int(initial_level, 0)
|
||||
levels[buff_key] = sanitized_level
|
||||
generator_buff_levels[generator_key] = levels
|
||||
generator_buff_level_changed.emit(StringName(generator_key), StringName(buff_key), sanitized_level)
|
||||
|
||||
func get_generator_buff_level(generator_id: StringName, buff_id: StringName) -> int:
|
||||
var buff_key: String = String(buff_id)
|
||||
if buff_key.is_empty():
|
||||
return 0
|
||||
|
||||
var levels: Dictionary = _get_generator_buff_levels_ref(generator_id)
|
||||
return _to_non_negative_int(levels.get(buff_key, 0), 0)
|
||||
|
||||
func set_generator_buff_level(generator_id: StringName, buff_id: StringName, value: int) -> void:
|
||||
var generator_key: String = String(generator_id)
|
||||
var buff_key: String = String(buff_id)
|
||||
if generator_key.is_empty() or buff_key.is_empty():
|
||||
return
|
||||
|
||||
var levels: Dictionary = _get_generator_buff_levels_ref(generator_id)
|
||||
var next_level: int = _to_non_negative_int(value, 0)
|
||||
if int(levels.get(buff_key, -1)) == next_level:
|
||||
return
|
||||
|
||||
levels[buff_key] = next_level
|
||||
generator_buff_levels[generator_key] = levels
|
||||
generator_buff_level_changed.emit(StringName(generator_key), StringName(buff_key), next_level)
|
||||
|
||||
func get_generator_buff_levels(generator_id: StringName) -> Dictionary:
|
||||
return _get_generator_buff_levels_ref(generator_id).duplicate(true)
|
||||
|
||||
# ==========================================
|
||||
# PERSISTENCE (Save/Load)
|
||||
# ==========================================
|
||||
func save_game() -> void:
|
||||
var save_data = {
|
||||
CURRENCIES_KEY: _serialize_currency_balances(),
|
||||
GENERATOR_STATES_KEY: _serialize_generator_states(),
|
||||
GENERATOR_BUFF_LEVELS_KEY: _serialize_generator_buff_levels(),
|
||||
LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system()
|
||||
}
|
||||
|
||||
var file: FileAccess = FileAccess.open(file_name, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(save_data))
|
||||
|
||||
func load_game() -> void:
|
||||
if not FileAccess.file_exists(file_name):
|
||||
return
|
||||
|
||||
var file: FileAccess = FileAccess.open(file_name, FileAccess.READ)
|
||||
if file:
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
if parsed is Dictionary:
|
||||
var parsed_data: Dictionary = parsed
|
||||
if parsed_data.has(CURRENCIES_KEY):
|
||||
_load_currency_balances(parsed_data.get(CURRENCIES_KEY, {}))
|
||||
|
||||
generator_states = _deserialize_generator_states(parsed_data.get(GENERATOR_STATES_KEY, {}))
|
||||
generator_buff_levels = _deserialize_generator_buff_levels(parsed_data.get(GENERATOR_BUFF_LEVELS_KEY, {}))
|
||||
last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system())
|
||||
|
||||
func _load_currency_balances(raw_currencies: Variant) -> void:
|
||||
if not (raw_currencies is Dictionary):
|
||||
return
|
||||
|
||||
var currencies: Dictionary = raw_currencies
|
||||
for raw_currency_id in currencies.keys():
|
||||
var currency_id: StringName = _normalize_currency_id(StringName(String(raw_currency_id)))
|
||||
if currency_id == &"":
|
||||
continue
|
||||
|
||||
var raw_entry: Variant = currencies.get(raw_currency_id)
|
||||
if not (raw_entry is Dictionary):
|
||||
continue
|
||||
|
||||
var entry: Dictionary = raw_entry
|
||||
var current_amount: BigNumber = BigNumber.deserialize(entry.get(CURRENT_KEY, {}))
|
||||
var total_amount: BigNumber = _deserialize_total_currency(entry.get(TOTAL_KEY, null), current_amount)
|
||||
_set_current_currency(currency_id, current_amount)
|
||||
_set_total_currency(currency_id, total_amount)
|
||||
|
||||
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 _serialize_currency_balances() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
|
||||
for currency_id in _collect_currency_ids_for_save():
|
||||
var key: String = String(currency_id)
|
||||
if key.is_empty():
|
||||
continue
|
||||
|
||||
result[key] = {
|
||||
CURRENT_KEY: _get_current_currency_ref(currency_id).serialize(),
|
||||
TOTAL_KEY: _get_total_currency_ref(currency_id).serialize(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
func _collect_currency_ids_for_save() -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
|
||||
for raw_id in _current_currency.keys():
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id)))
|
||||
if normalized_currency_id == &"" or ids.has(normalized_currency_id):
|
||||
continue
|
||||
ids.append(normalized_currency_id)
|
||||
|
||||
for known_currency_id in CurrencyDatabase.get_known_currency_ids():
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(known_currency_id)
|
||||
if normalized_currency_id == &"" or ids.has(normalized_currency_id):
|
||||
continue
|
||||
ids.append(normalized_currency_id)
|
||||
|
||||
return ids
|
||||
|
||||
func _initialize_currency_maps() -> void:
|
||||
for currency_id in CurrencyDatabase.get_known_currency_ids():
|
||||
if currency_id == &"":
|
||||
continue
|
||||
_set_current_currency(currency_id, BigNumber.from_float(0.0))
|
||||
_set_total_currency(currency_id, BigNumber.from_float(0.0))
|
||||
|
||||
func _normalize_currency_id(currency_id: StringName) -> StringName:
|
||||
var normalized: String = String(currency_id).to_lower().strip_edges()
|
||||
if normalized.is_empty():
|
||||
return &""
|
||||
return StringName(normalized)
|
||||
|
||||
func _get_current_currency_ref(currency_id: StringName) -> BigNumber:
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var value: Variant = _current_currency.get(normalized_currency_id)
|
||||
if value is BigNumber:
|
||||
return value
|
||||
|
||||
var fallback: BigNumber = BigNumber.from_float(0.0)
|
||||
_current_currency[normalized_currency_id] = fallback
|
||||
return fallback
|
||||
|
||||
func _get_total_currency_ref(currency_id: StringName) -> BigNumber:
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var value: Variant = _total_currency_acquired.get(normalized_currency_id)
|
||||
if value is BigNumber:
|
||||
return value
|
||||
|
||||
var fallback: BigNumber = BigNumber.from_float(0.0)
|
||||
_total_currency_acquired[normalized_currency_id] = fallback
|
||||
return fallback
|
||||
|
||||
func _set_current_currency(currency_id: StringName, value: BigNumber) -> void:
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return
|
||||
_current_currency[normalized_currency_id] = _copy_big_number(value)
|
||||
|
||||
func _set_total_currency(currency_id: StringName, value: BigNumber) -> void:
|
||||
var normalized_currency_id: StringName = _normalize_currency_id(currency_id)
|
||||
if normalized_currency_id == &"":
|
||||
return
|
||||
_total_currency_acquired[normalized_currency_id] = _copy_big_number(value)
|
||||
|
||||
func _default_generator_state(unlocked: bool, available: bool) -> Dictionary:
|
||||
return {
|
||||
GENERATOR_OWNED_KEY: 0,
|
||||
GENERATOR_PURCHASED_COUNT_KEY: 0,
|
||||
GENERATOR_UNLOCKED_KEY: unlocked,
|
||||
GENERATOR_AVAILABLE_KEY: available,
|
||||
}
|
||||
|
||||
func _get_generator_state(generator_id: StringName) -> Dictionary:
|
||||
var key: String = String(generator_id)
|
||||
if key.is_empty():
|
||||
return _default_generator_state(false, false)
|
||||
|
||||
if not generator_states.has(key):
|
||||
register_generator(generator_id)
|
||||
|
||||
var existing_raw: Variant = generator_states.get(key, _default_generator_state(false, false))
|
||||
if existing_raw is Dictionary:
|
||||
var state: Dictionary = existing_raw
|
||||
var sanitized: Dictionary = _sanitize_generator_state(state, false, false)
|
||||
generator_states[key] = sanitized
|
||||
return sanitized
|
||||
|
||||
var fallback: Dictionary = _default_generator_state(false, false)
|
||||
generator_states[key] = fallback
|
||||
return fallback
|
||||
|
||||
func _set_generator_state(generator_id: StringName, state: Dictionary) -> void:
|
||||
var key: String = String(generator_id)
|
||||
if key.is_empty():
|
||||
return
|
||||
|
||||
var sanitized: Dictionary = _sanitize_generator_state(state, false, false)
|
||||
generator_states[key] = sanitized
|
||||
generator_state_changed.emit(StringName(key), sanitized.duplicate(true))
|
||||
|
||||
func _get_generator_buff_levels_ref(generator_id: StringName) -> Dictionary:
|
||||
var key: String = String(generator_id)
|
||||
if key.is_empty():
|
||||
return {}
|
||||
|
||||
var existing_raw: Variant = generator_buff_levels.get(key, {})
|
||||
if existing_raw is Dictionary:
|
||||
var sanitized: Dictionary = _sanitize_generator_buff_levels(existing_raw)
|
||||
generator_buff_levels[key] = sanitized
|
||||
return sanitized
|
||||
|
||||
var fallback: Dictionary = {}
|
||||
generator_buff_levels[key] = fallback
|
||||
return fallback
|
||||
|
||||
func _sanitize_generator_buff_levels(raw_levels: Dictionary) -> Dictionary:
|
||||
var sanitized_levels: Dictionary = {}
|
||||
for buff_key_variant in raw_levels.keys():
|
||||
var buff_key: String = String(buff_key_variant)
|
||||
if buff_key.is_empty():
|
||||
continue
|
||||
|
||||
sanitized_levels[buff_key] = _to_non_negative_int(raw_levels.get(buff_key_variant, 0), 0)
|
||||
|
||||
return sanitized_levels
|
||||
|
||||
func _serialize_generator_states() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for key_variant in generator_states.keys():
|
||||
var key: String = String(key_variant)
|
||||
if key.is_empty():
|
||||
continue
|
||||
|
||||
var value: Variant = generator_states.get(key_variant)
|
||||
if value is Dictionary:
|
||||
result[key] = _serialize_generator_state_entry(value)
|
||||
return result
|
||||
|
||||
func _deserialize_generator_states(raw_value: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not (raw_value is Dictionary):
|
||||
return result
|
||||
|
||||
for key_variant in raw_value.keys():
|
||||
var key: String = String(key_variant)
|
||||
if key.is_empty():
|
||||
continue
|
||||
|
||||
var entry: Variant = raw_value.get(key_variant)
|
||||
if entry is Dictionary:
|
||||
result[key] = _deserialize_generator_state_entry(entry)
|
||||
return result
|
||||
|
||||
func _serialize_generator_buff_levels() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for generator_key_variant in generator_buff_levels.keys():
|
||||
var generator_key: String = String(generator_key_variant)
|
||||
if generator_key.is_empty():
|
||||
continue
|
||||
|
||||
var raw_levels: Variant = generator_buff_levels.get(generator_key_variant)
|
||||
if raw_levels is Dictionary:
|
||||
result[generator_key] = _sanitize_generator_buff_levels(raw_levels)
|
||||
|
||||
return result
|
||||
|
||||
func _deserialize_generator_buff_levels(raw_value: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not (raw_value is Dictionary):
|
||||
return result
|
||||
|
||||
for generator_key_variant in raw_value.keys():
|
||||
var generator_key: String = String(generator_key_variant)
|
||||
if generator_key.is_empty():
|
||||
continue
|
||||
|
||||
var raw_levels: Variant = raw_value.get(generator_key_variant)
|
||||
if raw_levels is Dictionary:
|
||||
result[generator_key] = _sanitize_generator_buff_levels(raw_levels)
|
||||
|
||||
return result
|
||||
|
||||
func _serialize_generator_state_entry(raw_state: Dictionary) -> Dictionary:
|
||||
var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0)
|
||||
var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value)
|
||||
|
||||
var serialized_state: Dictionary = {
|
||||
GENERATOR_OWNED_KEY: owned_value,
|
||||
GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value),
|
||||
}
|
||||
|
||||
if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool:
|
||||
serialized_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY)
|
||||
if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool:
|
||||
serialized_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY)
|
||||
|
||||
return serialized_state
|
||||
|
||||
func _deserialize_generator_state_entry(raw_state: Dictionary) -> Dictionary:
|
||||
var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0)
|
||||
var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value)
|
||||
|
||||
var parsed_state: Dictionary = {
|
||||
GENERATOR_OWNED_KEY: owned_value,
|
||||
GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value),
|
||||
}
|
||||
|
||||
if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool:
|
||||
parsed_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY)
|
||||
if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool:
|
||||
parsed_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY)
|
||||
|
||||
return parsed_state
|
||||
|
||||
func _sanitize_generator_state(raw_state: Dictionary, default_unlocked: bool, default_available: bool) -> Dictionary:
|
||||
var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0)
|
||||
var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value)
|
||||
|
||||
return {
|
||||
GENERATOR_OWNED_KEY: owned_value,
|
||||
GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value),
|
||||
GENERATOR_UNLOCKED_KEY: _to_bool(raw_state.get(GENERATOR_UNLOCKED_KEY, default_unlocked), default_unlocked),
|
||||
GENERATOR_AVAILABLE_KEY: _to_bool(raw_state.get(GENERATOR_AVAILABLE_KEY, default_available), default_available),
|
||||
}
|
||||
|
||||
func _to_non_negative_int(value: Variant, fallback: int = 0) -> int:
|
||||
if value is int:
|
||||
return maxi(value, 0)
|
||||
if value is float:
|
||||
return maxi(int(value), 0)
|
||||
return maxi(fallback, 0)
|
||||
|
||||
func _to_bool(value: Variant, fallback: bool) -> bool:
|
||||
if value is bool:
|
||||
return value
|
||||
return fallback
|
||||
1
core/game_state.gd.uid
Normal file
1
core/game_state.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2j7tvlgxr2jp
|
||||
197
core/generator-unlock-goals/TECH_SPEC.md
Normal file
197
core/generator-unlock-goals/TECH_SPEC.md
Normal 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.
|
||||
208
core/generator-unlock-goals/generator_unlock_system.gd
Normal file
208
core/generator-unlock-goals/generator_unlock_system.gd
Normal file
@@ -0,0 +1,208 @@
|
||||
extends Node
|
||||
|
||||
class UnlockGoalRequirement extends RefCounted:
|
||||
var currency_id: StringName
|
||||
var amount: BigNumber
|
||||
|
||||
func _init(req_currency_id: StringName, req_amount: BigNumber) -> void:
|
||||
currency_id = req_currency_id
|
||||
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://idles/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)
|
||||
GameState.generator_state_changed.connect(_on_generator_state_changed)
|
||||
_evaluate_all_goals()
|
||||
_evaluate_all_goals.call_deferred()
|
||||
|
||||
func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void:
|
||||
_evaluate_all_goals()
|
||||
|
||||
func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) -> void:
|
||||
var generator_key: String = String(generator_id)
|
||||
if not generator_key.is_empty():
|
||||
_known_generator_ids[generator_key] = true
|
||||
_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_id)
|
||||
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_id: StringName = _parse_currency(requirement_dict.get("currency", ""))
|
||||
if currency_id == &"" or not GameState.is_known_currency_id(currency_id):
|
||||
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_id, amount)
|
||||
|
||||
func _parse_currency(raw_currency: Variant) -> StringName:
|
||||
return GameState.parse_currency_id(raw_currency)
|
||||
|
||||
func _get_total_currency_amount(currency_id: StringName) -> BigNumber:
|
||||
return GameState.get_total_currency_acquired_by_id(currency_id)
|
||||
|
||||
func _collect_known_generator_ids() -> void:
|
||||
_known_generator_ids.clear()
|
||||
|
||||
var scene_root: Node = get_tree().current_scene
|
||||
var search_root: Node = scene_root if scene_root != null else self
|
||||
var generator_nodes: Array[Node] = search_root.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
|
||||
@@ -0,0 +1 @@
|
||||
uid://1sykgtg24a7g
|
||||
298
core/generator-unlock-goals/goals_debug_ui.gd
Normal file
298
core/generator-unlock-goals/goals_debug_ui.gd
Normal file
@@ -0,0 +1,298 @@
|
||||
extends PanelContainer
|
||||
|
||||
class GoalRequirement extends RefCounted:
|
||||
var currency_id: StringName
|
||||
var amount: BigNumber
|
||||
|
||||
func _init(req_currency_id: StringName, req_amount: BigNumber) -> void:
|
||||
currency_id = req_currency_id
|
||||
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://idles/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 _known_generator_ids: Dictionary = {}
|
||||
var _warned_unknown_target_ids: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
_collect_known_generator_ids()
|
||||
_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_id: StringName, _new_amount: BigNumber) -> void:
|
||||
_refresh_ui()
|
||||
|
||||
func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) -> void:
|
||||
var generator_key: String = String(generator_id)
|
||||
if not generator_key.is_empty():
|
||||
_known_generator_ids[generator_key] = true
|
||||
_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_id: StringName = _parse_currency(requirement_dict.get("currency", ""))
|
||||
if currency_id == &"" or not GameState.is_known_currency_id(currency_id):
|
||||
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_id, amount)
|
||||
|
||||
func _parse_currency(raw_currency: Variant) -> StringName:
|
||||
return GameState.parse_currency_id(raw_currency)
|
||||
|
||||
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):
|
||||
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)
|
||||
var can_resolve_target: bool = _can_resolve_target(goal.target_generator_id)
|
||||
var met: bool = _is_goal_met(goal)
|
||||
var can_unlock: bool = can_resolve_target and 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 not can_resolve_target:
|
||||
row.status_label.text = "Missing"
|
||||
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_by_id(requirement.currency_id)
|
||||
if total_amount.is_less_than(requirement.amount):
|
||||
return false
|
||||
return true
|
||||
|
||||
func _is_goal_completed(goal: GoalDefinition) -> bool:
|
||||
return (
|
||||
GameState.is_generator_unlocked(goal.target_generator_id)
|
||||
and GameState.is_generator_available(goal.target_generator_id)
|
||||
)
|
||||
|
||||
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):
|
||||
return
|
||||
if not _can_resolve_target(goal.target_generator_id):
|
||||
return
|
||||
if not _is_goal_met(goal):
|
||||
return
|
||||
|
||||
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 _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("Goals debug UI target generator not found in scene/state: '%s'" % target_key)
|
||||
return false
|
||||
|
||||
func _collect_known_generator_ids() -> void:
|
||||
_known_generator_ids.clear()
|
||||
|
||||
var scene_root: Node = get_tree().current_scene
|
||||
var search_root: Node = scene_root if scene_root != null else self
|
||||
var generator_nodes: Array[Node] = search_root.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
|
||||
|
||||
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_by_id(requirement.currency_id)
|
||||
parts.append(
|
||||
"%s %s / %s" % [
|
||||
_currency_label(requirement.currency_id),
|
||||
total_amount.to_string_suffix(2),
|
||||
requirement.amount.to_string_suffix(2)
|
||||
]
|
||||
)
|
||||
return " | ".join(parts)
|
||||
|
||||
func _currency_label(currency_id: StringName) -> String:
|
||||
return GameState.get_currency_name(currency_id)
|
||||
1
core/generator-unlock-goals/goals_debug_ui.gd.uid
Normal file
1
core/generator-unlock-goals/goals_debug_ui.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmrbaulftvvwm
|
||||
40
core/generator-unlock-goals/goals_debug_ui.tscn
Normal file
40
core/generator-unlock-goals/goals_debug_ui.tscn
Normal file
@@ -0,0 +1,40 @@
|
||||
[gd_scene format=3 uid="uid://dirvi76rkoowf"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://bmrbaulftvvwm" path="res://core/generator-unlock-goals/goals_debug_ui.gd" id="1_s7s8v"]
|
||||
|
||||
[node name="GoalsDebugUI" type="PanelContainer" unique_id=1494700185]
|
||||
offset_right = 640.0
|
||||
offset_bottom = 260.0
|
||||
script = ExtResource("1_s7s8v")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1582529872]
|
||||
layout_mode = 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" unique_id=1555356181]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=811777252]
|
||||
layout_mode = 2
|
||||
text = "Goals Debug"
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=17196021]
|
||||
layout_mode = 2
|
||||
text = "Goals: 0 | Ready: 0 | Unlocked: 0"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer" unique_id=1105060075]
|
||||
custom_minimum_size = Vector2(0, 170)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="GoalsList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer" unique_id=1684633480]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
521
core/generator/currency_generator.gd
Normal file
521
core/generator/currency_generator.gd
Normal file
@@ -0,0 +1,521 @@
|
||||
class_name CurrencyGenerator
|
||||
extends Node2D
|
||||
|
||||
## Handles generator economy behavior:
|
||||
## - automatic production cycles
|
||||
## - manual/hover click rewards with cooldown
|
||||
## - purchasing and max-buy
|
||||
## - generator state access through the GameState autoload
|
||||
|
||||
## Emitted after a successful purchase.
|
||||
signal purchase_completed(amount: int, total_owned: int, total_purchased: int, total_cost: BigNumber)
|
||||
## Emitted when a purchase is attempted but cannot be paid.
|
||||
signal purchase_failed(requested_amount: int, required_cost: BigNumber, available_currency: BigNumber)
|
||||
## Emitted when one or more automatic production cycles complete.
|
||||
signal production_tick(amount: BigNumber, cycle_count: int, total_owned: int)
|
||||
## Emitted after a successful buff purchase.
|
||||
signal buff_purchased(buff_id: StringName, new_level: int, cost: BigNumber, cost_currency_id: StringName)
|
||||
## Emitted when a buff purchase cannot be paid.
|
||||
signal buff_purchase_failed(buff_id: StringName, required_cost: BigNumber, cost_currency_id: StringName)
|
||||
|
||||
## Sentinel exponent used when cost math overflows float range.
|
||||
const HUGE_COST_EXPONENT: int = 1000000
|
||||
|
||||
## Currency target updated by this generator.
|
||||
@export var currency: Resource
|
||||
## Data resource containing balancing values and defaults (required).
|
||||
@export var data: CurrencyGeneratorData
|
||||
## Enables per-cycle automatic production when true.
|
||||
@export var is_automatic: bool = true
|
||||
## If true, `_on_pressed` buys one generator instead of granting click currency.
|
||||
@export var press_buys_generator: bool = true
|
||||
## If true, hovering grants click reward every cooldown without pressing.
|
||||
@export var grants_click_while_hovering: bool = false
|
||||
## Extra multiplier applied to automatic production output.
|
||||
@export var run_multiplier: float = 1.0
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
## Number of currently owned generators.
|
||||
## Backed by GameState via this generator's resolved id.
|
||||
var owned: int:
|
||||
get:
|
||||
_ensure_registered()
|
||||
return GameState.get_generator_owned(_generator_id)
|
||||
set(value):
|
||||
_ensure_registered()
|
||||
GameState.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)
|
||||
set(value):
|
||||
_ensure_registered()
|
||||
GameState.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)
|
||||
set(value):
|
||||
_ensure_registered()
|
||||
GameState.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)
|
||||
set(value):
|
||||
_ensure_registered()
|
||||
GameState.set_generator_available(_generator_id, value)
|
||||
|
||||
## Accumulated elapsed time toward the next automatic production cycle.
|
||||
var cycle_progress_seconds: float = 0.0
|
||||
## Stable id used to store/read generator state in GameState.
|
||||
var _generator_id: StringName = &""
|
||||
## Prevents duplicate state registration in GameState.
|
||||
var _is_registered: bool = false
|
||||
|
||||
|
||||
## Resolves and registers this generator state before gameplay updates.
|
||||
func _ready() -> void:
|
||||
assert(currency != null, "Currency cannot be null")
|
||||
assert(data != null, "Data cannot be null")
|
||||
|
||||
_generator_id = _resolve_generator_id()
|
||||
_ensure_registered()
|
||||
cycle_progress_seconds = 0.0
|
||||
|
||||
GameState.generator_state_changed.connect(_on_generated_state_changed)
|
||||
|
||||
## Updates click cooldown/click grants and runs automatic production cycles.
|
||||
func _process(delta: float) -> void:
|
||||
_remaining_click_cooldown_seconds = maxf(_remaining_click_cooldown_seconds - maxf(delta, 0.0), 0.0)
|
||||
_try_handle_mouse_click()
|
||||
|
||||
if not is_automatic:
|
||||
return
|
||||
if not is_available_to_player():
|
||||
return
|
||||
if data == null or owned <= 0:
|
||||
return
|
||||
|
||||
var cycle_seconds: float = maxf(data.initial_time, 0.0001)
|
||||
cycle_progress_seconds += maxf(delta, 0.0)
|
||||
if cycle_progress_seconds < cycle_seconds:
|
||||
return
|
||||
|
||||
var completed_cycles: int = floori(cycle_progress_seconds / cycle_seconds)
|
||||
cycle_progress_seconds -= cycle_seconds * float(completed_cycles)
|
||||
_grant_cycle_income(completed_cycles)
|
||||
|
||||
## Adds automatic production from completed cycles and emits `production_tick`.
|
||||
func _grant_cycle_income(cycle_count: int) -> void:
|
||||
if data == null or cycle_count <= 0:
|
||||
return
|
||||
|
||||
var effective_run_multiplier: float = run_multiplier * get_automatic_production_multiplier()
|
||||
var per_cycle: float = data.production_per_cycle(owned, effective_run_multiplier, purchased_count)
|
||||
if per_cycle <= 0.0:
|
||||
return
|
||||
|
||||
var produced: BigNumber = BigNumber.from_float(per_cycle).multiply(BigNumber.from_float(float(cycle_count)))
|
||||
_add_currency(produced)
|
||||
production_tick.emit(produced, cycle_count, owned)
|
||||
|
||||
## Convenience helper for the next single-unit purchase cost.
|
||||
func get_next_cost() -> BigNumber:
|
||||
return get_cost_for_amount(1)
|
||||
|
||||
## Returns cumulative cost to buy `amount` units from current ownership.
|
||||
func get_cost_for_amount(amount: int = 1) -> BigNumber:
|
||||
if data == null:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var safe_amount: int = maxi(amount, 1)
|
||||
var raw_cost: float = data.cost_for_amount(owned, safe_amount)
|
||||
return _float_to_big_number(raw_cost)
|
||||
|
||||
## Checks whether enough target currency is available for `amount` units.
|
||||
func can_buy(amount: int = 1) -> bool:
|
||||
if not is_available_to_player():
|
||||
return false
|
||||
|
||||
var cost: BigNumber = get_cost_for_amount(amount)
|
||||
var current: BigNumber = _get_currency_amount()
|
||||
return current.is_greater_than(cost) or current.is_equal_to(cost)
|
||||
|
||||
## Attempts to buy `amount` units and emits purchase success/failure signals.
|
||||
func buy(amount: int = 1) -> bool:
|
||||
if data == null:
|
||||
return false
|
||||
if not is_available_to_player():
|
||||
return false
|
||||
|
||||
var safe_amount: int = maxi(amount, 1)
|
||||
var total_cost: BigNumber = get_cost_for_amount(safe_amount)
|
||||
if not _spend_currency(total_cost):
|
||||
purchase_failed.emit(safe_amount, total_cost, _get_currency_amount())
|
||||
return false
|
||||
|
||||
owned += safe_amount
|
||||
purchased_count += safe_amount
|
||||
purchase_completed.emit(safe_amount, owned, purchased_count, total_cost)
|
||||
return true
|
||||
|
||||
## Computes and buys the maximum affordable units with current currency.
|
||||
func buy_max() -> int:
|
||||
if data == null:
|
||||
return 0
|
||||
if not is_available_to_player():
|
||||
return 0
|
||||
|
||||
var available_currency: float = _big_number_to_float(_get_currency_amount())
|
||||
var estimated_max: int = data.max_affordable(owned, available_currency)
|
||||
if estimated_max <= 0:
|
||||
return 0
|
||||
|
||||
# Keep max-buy accurate by validating affordability with BigNumber comparisons.
|
||||
var low: int = 1
|
||||
var high: int = estimated_max
|
||||
var best: int = 0
|
||||
while low <= high:
|
||||
var mid: int = floori((float(low) + float(high)) * 0.5)
|
||||
if can_buy(mid):
|
||||
best = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
|
||||
if best <= 0:
|
||||
return 0
|
||||
return best if buy(best) else 0
|
||||
|
||||
func get_buffs() -> Array[GeneratorBuffData]:
|
||||
if data == null:
|
||||
return []
|
||||
return data.buffs
|
||||
|
||||
func get_buff_level(buff_id: StringName) -> int:
|
||||
_ensure_registered()
|
||||
return GameState.get_generator_buff_level(_generator_id, buff_id)
|
||||
|
||||
func get_buff_cost(buff_id: StringName) -> BigNumber:
|
||||
var buff: GeneratorBuffData = _find_buff(buff_id)
|
||||
if buff == null:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var level: int = get_buff_level(buff.id)
|
||||
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 buff == null:
|
||||
return &""
|
||||
return _resolve_buff_cost_currency_id(buff)
|
||||
|
||||
func can_buy_buff(buff_id: StringName) -> bool:
|
||||
if not is_available_to_player():
|
||||
return false
|
||||
|
||||
var buff: GeneratorBuffData = _find_buff(buff_id)
|
||||
if buff == null:
|
||||
return false
|
||||
|
||||
var current_level: int = get_buff_level(buff.id)
|
||||
if not buff.can_purchase_next_level(current_level):
|
||||
return false
|
||||
|
||||
var cost_currency_id: StringName = _resolve_buff_cost_currency_id(buff)
|
||||
if cost_currency_id == &"":
|
||||
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)
|
||||
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 buff == null:
|
||||
return false
|
||||
|
||||
var current_level: int = get_buff_level(buff.id)
|
||||
if not buff.can_purchase_next_level(current_level):
|
||||
return false
|
||||
|
||||
var cost: BigNumber = buff.get_cost_for_level(current_level)
|
||||
var cost_currency_id: StringName = _resolve_buff_cost_currency_id(buff)
|
||||
if cost_currency_id == &"":
|
||||
return false
|
||||
|
||||
if not _spend_currency_by_id(cost_currency_id, cost):
|
||||
buff_purchase_failed.emit(buff.id, cost, cost_currency_id)
|
||||
return false
|
||||
|
||||
var next_level: int = current_level + 1
|
||||
GameState.set_generator_buff_level(_generator_id, 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 buff == null:
|
||||
return true
|
||||
|
||||
return not buff.can_purchase_next_level(get_buff_level(buff.id))
|
||||
|
||||
func get_automatic_production_multiplier() -> float:
|
||||
return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER)
|
||||
|
||||
func get_manual_click_multiplier() -> float:
|
||||
return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.MANUAL_CLICK_MULTIPLIER)
|
||||
|
||||
## Handles external "pressed" interaction: buy if configured, otherwise click-grant.
|
||||
func _on_pressed() -> void:
|
||||
if not is_available_to_player():
|
||||
return
|
||||
|
||||
if press_buys_generator:
|
||||
buy(1)
|
||||
return
|
||||
|
||||
_try_grant_click_currency()
|
||||
|
||||
## Adds currency directly through the configured currency target.
|
||||
func grant_currency(amount: BigNumber) -> void:
|
||||
_add_currency(amount)
|
||||
|
||||
## Processes pointer interaction while hovering (hold click or hover-only mode).
|
||||
func _try_handle_mouse_click() -> void:
|
||||
if not _mouse_entered:
|
||||
return
|
||||
if not is_available_to_player():
|
||||
return
|
||||
if not (_grants_click_while_hovering() or Input.is_action_pressed("click")):
|
||||
return
|
||||
|
||||
_try_grant_click_currency()
|
||||
|
||||
## Grants one click/hover reward if cooldown has elapsed.
|
||||
func _try_grant_click_currency() -> void:
|
||||
if data == null:
|
||||
return
|
||||
if _remaining_click_cooldown_seconds > 0.0:
|
||||
return
|
||||
|
||||
_add_currency(_get_click_value())
|
||||
_remaining_click_cooldown_seconds = _get_click_cooldown_seconds()
|
||||
|
||||
## Resolves click reward from data.
|
||||
func _get_click_value() -> BigNumber:
|
||||
if data == null:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var click_multiplier: float = get_manual_click_multiplier()
|
||||
if click_multiplier <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var base_click: BigNumber = BigNumber.new(data.click_mantissa, data.click_exponent)
|
||||
return base_click.multiply(BigNumber.from_float(click_multiplier))
|
||||
|
||||
## Resolves click cooldown from data.
|
||||
func _get_click_cooldown_seconds() -> float:
|
||||
if data == null:
|
||||
return 0.0
|
||||
return maxf(data.click_cooldown_seconds, 0.0)
|
||||
|
||||
## Resolves hover-only click-grant mode from node configuration.
|
||||
func _grants_click_while_hovering() -> bool:
|
||||
return grants_click_while_hovering
|
||||
|
||||
## Returns this generator's resolved state id.
|
||||
func get_generator_id() -> StringName:
|
||||
_ensure_registered()
|
||||
return _generator_id
|
||||
|
||||
## Returns the configured currency id.
|
||||
func get_currency_id() -> StringName:
|
||||
return GameState.get_currency_id(currency)
|
||||
|
||||
## True when the generator is both unlocked and available.
|
||||
func is_available_to_player() -> bool:
|
||||
return is_unlocked and is_available
|
||||
|
||||
## Routes positive/negative currency deltas to the configured currency bucket.
|
||||
func _add_currency(amount: BigNumber) -> void:
|
||||
if currency == null:
|
||||
push_error("CurrencyGenerator '%s' cannot add currency: currency resource is null." % String(name))
|
||||
return
|
||||
|
||||
GameState.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)
|
||||
|
||||
## Attempts to spend target currency; returns false when insufficient.
|
||||
func _spend_currency(cost: BigNumber) -> bool:
|
||||
if currency == null:
|
||||
push_error("CurrencyGenerator '%s' cannot spend currency: currency resource is null." % String(name))
|
||||
return false
|
||||
|
||||
return GameState.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)
|
||||
|
||||
## 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)
|
||||
|
||||
## Safely converts finite float values into BigNumber costs.
|
||||
func _float_to_big_number(value: float) -> BigNumber:
|
||||
if value <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
if not is_finite(value):
|
||||
return BigNumber.new(1.0, HUGE_COST_EXPONENT)
|
||||
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
|
||||
if cost_currency == null:
|
||||
return &""
|
||||
|
||||
return GameState.get_currency_id(cost_currency)
|
||||
|
||||
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
|
||||
if target_currency == null:
|
||||
return &""
|
||||
|
||||
return GameState.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
|
||||
|
||||
multiplier *= buff.get_effect_multiplier(get_buff_level(buff.id))
|
||||
|
||||
return maxf(multiplier, 0.0)
|
||||
|
||||
func _apply_resource_purchase_buff(buff: GeneratorBuffData, level: int) -> void:
|
||||
if buff == null:
|
||||
return
|
||||
if buff.kind != GeneratorBuffData.BuffKind.RESOURCE_PURCHASE:
|
||||
return
|
||||
|
||||
var target_currency_id: StringName = _resolve_buff_target_currency_id(buff)
|
||||
if target_currency_id == &"":
|
||||
return
|
||||
|
||||
var purchased_amount: BigNumber = buff.get_resource_purchase_amount_for_level(level)
|
||||
if purchased_amount.mantissa <= 0.0:
|
||||
return
|
||||
|
||||
_add_currency_by_id(target_currency_id, purchased_amount)
|
||||
|
||||
## Builds a stable state id from data id, node name, or fallback string.
|
||||
func _resolve_generator_id() -> StringName:
|
||||
if data != null:
|
||||
var data_id: String = String(data.id)
|
||||
if not data_id.is_empty():
|
||||
return StringName(data_id)
|
||||
|
||||
if not String(name).is_empty():
|
||||
return StringName(String(name))
|
||||
|
||||
return &"generator"
|
||||
|
||||
## Ensures this generator has a state entry in GameState.
|
||||
func _ensure_registered() -> void:
|
||||
if _is_registered:
|
||||
return
|
||||
|
||||
if _generator_id == &"":
|
||||
_generator_id = _resolve_generator_id()
|
||||
if _generator_id == &"":
|
||||
return
|
||||
|
||||
GameState.register_generator(_generator_id, _default_unlocked_state(), _default_available_state())
|
||||
_register_buffs()
|
||||
_is_registered = true
|
||||
|
||||
func _register_buffs() -> void:
|
||||
for buff in get_buffs():
|
||||
if buff == null:
|
||||
continue
|
||||
|
||||
var buff_id_text: String = String(buff.id).strip_edges()
|
||||
if buff_id_text.is_empty():
|
||||
continue
|
||||
|
||||
GameState.register_generator_buff(_generator_id, StringName(buff_id_text), 0)
|
||||
|
||||
## Default unlocked state loaded from generator data.
|
||||
func _default_unlocked_state() -> bool:
|
||||
if data == null:
|
||||
return false
|
||||
return data.starts_unlocked
|
||||
|
||||
## Default available state loaded from generator data.
|
||||
func _default_available_state() -> bool:
|
||||
if data == null:
|
||||
return false
|
||||
return data.starts_available
|
||||
|
||||
## Area2D callback: pointer entered generator interaction region.
|
||||
func _on_area_2d_mouse_entered() -> void:
|
||||
_mouse_entered = true
|
||||
|
||||
## Area2D callback: pointer exited generator interaction region.
|
||||
func _on_area_2d_mouse_exited() -> void:
|
||||
_mouse_entered = false
|
||||
|
||||
func _on_generated_state_changed(generator_id: StringName, _state: Dictionary) -> void:
|
||||
if generator_id == _generator_id:
|
||||
visible = true
|
||||
1
core/generator/currency_generator.gd.uid
Normal file
1
core/generator/currency_generator.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dtbxopw6ulhl8
|
||||
25
core/generator/currency_generator.tscn
Normal file
25
core/generator/currency_generator.tscn
Normal file
@@ -0,0 +1,25 @@
|
||||
[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/primary_generator.tres" id="3_wx13b"]
|
||||
[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="4_ruf1h"]
|
||||
|
||||
[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")
|
||||
|
||||
[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"]
|
||||
150
core/generator/currency_generator_data.gd
Normal file
150
core/generator/currency_generator_data.gd
Normal file
@@ -0,0 +1,150 @@
|
||||
class_name CurrencyGeneratorData
|
||||
extends Resource
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var name: String = ""
|
||||
@export var starts_unlocked: bool = true
|
||||
@export var starts_available: bool = true
|
||||
|
||||
## Base cost and exponential growth (part I): cost_next = b * r^owned
|
||||
@export var initial_cost: float = 10.0
|
||||
@export var coefficient: float = 1.15
|
||||
|
||||
## Generator timing and output.
|
||||
@export var initial_time: float = 1.0
|
||||
@export var initial_revenue: float = 1.0
|
||||
@export var initial_productivity: float = 1.0
|
||||
|
||||
## Milestone boosts (part I), e.g. every 25 purchased grant x2.
|
||||
@export var milestone_step: int = 25
|
||||
@export var milestone_multiplier: float = 2.0
|
||||
|
||||
## Optional purchased-only boost from derivative-style models (part II).
|
||||
## Example: 0.05 means each purchased unit adds +0.05% to output.
|
||||
@export var purchased_boost_percent: float = 0.0
|
||||
|
||||
## Manual click reward for this generator.
|
||||
@export var click_mantissa: float = 1.0
|
||||
@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] = []
|
||||
|
||||
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)
|
||||
|
||||
## Geometric-series bulk-buy cost (part I):
|
||||
## total = b * r^k * ((r^n - 1) / (r - 1))
|
||||
func cost_for_amount(owned: int, amount: int = 1) -> float:
|
||||
var safe_owned: int = maxi(owned, 0)
|
||||
var safe_amount: int = maxi(amount, 0)
|
||||
if safe_amount == 0:
|
||||
return 0.0
|
||||
if initial_cost <= 0.0:
|
||||
return 0.0
|
||||
if coefficient <= 0.0:
|
||||
return INF
|
||||
|
||||
if is_equal_approx(coefficient, 1.0):
|
||||
return initial_cost * float(safe_amount)
|
||||
|
||||
var r_to_owned: float = pow(coefficient, float(safe_owned))
|
||||
var r_to_amount: float = pow(coefficient, float(safe_amount))
|
||||
return initial_cost * r_to_owned * ((r_to_amount - 1.0) / (coefficient - 1.0))
|
||||
|
||||
## Closed-form max-buy estimate from available currency (part I):
|
||||
## max = floor(log((c(r-1)/(b*r^k))+1) / log(r))
|
||||
func max_affordable(owned: int, currency: float) -> int:
|
||||
if currency <= 0.0:
|
||||
return 0
|
||||
if initial_cost <= 0.0:
|
||||
return 0
|
||||
if coefficient <= 0.0:
|
||||
return 0
|
||||
|
||||
var safe_owned: int = maxi(owned, 0)
|
||||
if is_equal_approx(coefficient, 1.0):
|
||||
return maxi(0, floori(currency / initial_cost))
|
||||
|
||||
var denominator: float = initial_cost * pow(coefficient, float(safe_owned))
|
||||
if denominator <= 0.0:
|
||||
return 0
|
||||
|
||||
var inside: float = (currency * (coefficient - 1.0) / denominator) + 1.0
|
||||
if inside <= 1.0:
|
||||
return 0
|
||||
|
||||
return maxi(0, floori(log(inside) / log(coefficient)))
|
||||
|
||||
func milestone_count(owned: int) -> int:
|
||||
if milestone_step <= 0:
|
||||
return 0
|
||||
return maxi(owned, 0) / milestone_step
|
||||
|
||||
func milestone_multiplier_total(owned: int) -> float:
|
||||
if milestone_multiplier <= 1.0:
|
||||
return 1.0
|
||||
var count: int = milestone_count(owned)
|
||||
if count <= 0:
|
||||
return 1.0
|
||||
return pow(milestone_multiplier, float(count))
|
||||
|
||||
func purchased_multiplier(purchased: int) -> float:
|
||||
if purchased_boost_percent <= 0.0:
|
||||
return 1.0
|
||||
return 1.0 + (float(maxi(purchased, 0)) * purchased_boost_percent * 0.01)
|
||||
|
||||
func production_per_cycle(owned: int, run_multiplier: float = 1.0, purchased: int = -1) -> float:
|
||||
var safe_owned: int = maxi(owned, 0)
|
||||
if safe_owned == 0:
|
||||
return 0.0
|
||||
|
||||
var purchased_count: int = safe_owned if purchased < 0 else maxi(purchased, 0)
|
||||
var milestone_mult: float = milestone_multiplier_total(safe_owned)
|
||||
var purchased_mult: float = purchased_multiplier(purchased_count)
|
||||
|
||||
return initial_productivity * float(safe_owned) * run_multiplier * milestone_mult * purchased_mult
|
||||
|
||||
func production_per_second(owned: int, run_multiplier: float = 1.0, purchased: int = -1) -> float:
|
||||
return production_per_cycle(owned, run_multiplier, purchased) / maxf(initial_time, 0.0001)
|
||||
|
||||
## Returns value produced
|
||||
func production_total(owned: int, multipliers: float) -> float:
|
||||
return production_per_second(owned, multipliers)
|
||||
|
||||
func payback_seconds(owned: int, amount: int = 1, run_multiplier: float = 1.0, purchased: int = -1) -> float:
|
||||
var safe_amount: int = maxi(amount, 1)
|
||||
var upgrade_cost: float = cost_for_amount(owned, safe_amount)
|
||||
if not is_finite(upgrade_cost):
|
||||
return INF
|
||||
|
||||
var next_owned: int = maxi(owned, 0) + safe_amount
|
||||
var projected_rate: float = production_per_second(next_owned, run_multiplier, purchased)
|
||||
if projected_rate <= 0.0:
|
||||
return INF
|
||||
|
||||
return upgrade_cost / projected_rate
|
||||
|
||||
func income_to_cost_ratio(owned: int, amount: int = 1, run_multiplier: float = 1.0, purchased: int = -1) -> float:
|
||||
var safe_amount: int = maxi(amount, 1)
|
||||
var upgrade_cost: float = cost_for_amount(owned, safe_amount)
|
||||
if upgrade_cost <= 0.0 or not is_finite(upgrade_cost):
|
||||
return 0.0
|
||||
|
||||
var projected_rate: float = production_per_second(maxi(owned, 0) + safe_amount, run_multiplier, purchased)
|
||||
return projected_rate / upgrade_cost
|
||||
1
core/generator/currency_generator_data.gd.uid
Normal file
1
core/generator/currency_generator_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b00tqsuhxdy0d
|
||||
95
core/generator/generator_buff_data.gd
Normal file
95
core/generator/generator_buff_data.gd
Normal file
@@ -0,0 +1,95 @@
|
||||
class_name GeneratorBuffData
|
||||
extends Resource
|
||||
|
||||
enum BuffKind {
|
||||
AUTO_PRODUCTION_MULTIPLIER,
|
||||
MANUAL_CLICK_MULTIPLIER,
|
||||
RESOURCE_PURCHASE,
|
||||
}
|
||||
|
||||
const HUGE_EXPONENT: int = 1000000
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var kind: BuffKind = BuffKind.AUTO_PRODUCTION_MULTIPLIER
|
||||
@export_multiline var text: String = ""
|
||||
@export var icon: Texture2D
|
||||
@export var max_level: int = -1
|
||||
|
||||
@export_group("Effect")
|
||||
@export var base_effect: float = 1.0
|
||||
@export var effect_increment: float = 0.1
|
||||
|
||||
@export_group("Cost")
|
||||
@export var cost_currency: Resource
|
||||
@export var base_cost_mantissa: float = 10.0
|
||||
@export var base_cost_exponent: int = 0
|
||||
@export var cost_multiplier: float = 1.5
|
||||
|
||||
@export_group("Resource Purchase")
|
||||
@export var resource_target_currency: Resource
|
||||
@export var resource_purchase_base_mantissa: float = 10.0
|
||||
@export var resource_purchase_base_exponent: int = 0
|
||||
@export var resource_purchase_increment_multiplier: float = 1.2
|
||||
|
||||
func can_purchase_next_level(current_level: int) -> bool:
|
||||
if max_level < 0:
|
||||
return true
|
||||
return maxi(current_level, 0) < max_level
|
||||
|
||||
func get_effect_multiplier(level: int) -> float:
|
||||
var safe_level: int = maxi(level, 0)
|
||||
return maxf(base_effect + (effect_increment * float(safe_level)), 0.0)
|
||||
|
||||
func get_cost_for_level(level: int) -> BigNumber:
|
||||
var safe_level: int = maxi(level, 0)
|
||||
var base_cost: BigNumber = BigNumber.new(base_cost_mantissa, base_cost_exponent)
|
||||
if base_cost.mantissa <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
if cost_multiplier <= 0.0:
|
||||
return BigNumber.new(1.0, HUGE_EXPONENT)
|
||||
|
||||
var growth: float = 1.0
|
||||
if not is_equal_approx(cost_multiplier, 1.0):
|
||||
growth = pow(cost_multiplier, float(safe_level))
|
||||
if not is_finite(growth):
|
||||
return BigNumber.new(1.0, HUGE_EXPONENT)
|
||||
|
||||
return _scale_big_number(base_cost, growth)
|
||||
|
||||
func get_resource_purchase_amount_for_level(level: int) -> BigNumber:
|
||||
var safe_level: int = maxi(level, 0)
|
||||
var base_amount: BigNumber = BigNumber.new(resource_purchase_base_mantissa, resource_purchase_base_exponent)
|
||||
if base_amount.mantissa <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
if resource_purchase_increment_multiplier <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
|
||||
var growth: float = 1.0
|
||||
if not is_equal_approx(resource_purchase_increment_multiplier, 1.0):
|
||||
growth = pow(resource_purchase_increment_multiplier, float(safe_level))
|
||||
if not is_finite(growth):
|
||||
return BigNumber.new(1.0, HUGE_EXPONENT)
|
||||
|
||||
return _scale_big_number(base_amount, growth)
|
||||
|
||||
func get_effect_description(level: int) -> String:
|
||||
var safe_level: int = maxi(level, 0)
|
||||
match kind:
|
||||
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 "-"
|
||||
|
||||
func _scale_big_number(base_amount: BigNumber, scale: float) -> BigNumber:
|
||||
if base_amount.mantissa <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
if scale <= 0.0:
|
||||
return BigNumber.from_float(0.0)
|
||||
if not is_finite(scale):
|
||||
return BigNumber.new(1.0, HUGE_EXPONENT)
|
||||
|
||||
return base_amount.multiply(BigNumber.from_float(scale))
|
||||
1
core/generator/generator_buff_data.gd.uid
Normal file
1
core/generator/generator_buff_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnhocfsuvmeyb
|
||||
Reference in New Issue
Block a user