Change project organization

This commit is contained in:
2026-03-19 22:18:34 +01:00
parent 278b073501
commit 3c8709c834
39 changed files with 74 additions and 75 deletions

240
core/big_number.gd Normal file
View 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
View File

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

View File

@@ -0,0 +1,6 @@
class_name Currency
extends Resource
@export var id: StringName = &""
@export var display_name: String = ""
@export var icon: Texture2D

View File

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

164
core/currency_database.gd Normal file
View 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()

View File

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

569
core/game_state.gd Normal file
View 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
View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View 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)

View File

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

View 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

View 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

View File

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

View 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"]

View 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

View File

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

View 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))

View File

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