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) const GOLD_CURRENCY: Resource = preload("res://currency/gold.tres") const GEMS_CURRENCY: Resource = preload("res://currency/gems.tres") const DEFAULT_CURRENCIES: Array[Resource] = [GOLD_CURRENCY, GEMS_CURRENCY] 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, } # ========================================== # STATE VARIABLES # ========================================== var _current_currency: Dictionary = {} var _total_currency_acquired: Dictionary = {} var _currency_by_id: Dictionary = {} var gold: BigNumber: get: return _get_current_currency_ref(GOLD_CURRENCY_ID) set(value): _set_current_currency(GOLD_CURRENCY_ID, value) var gems: BigNumber: get: return _get_current_currency_ref(GEMS_CURRENCY_ID) set(value): _set_current_currency(GEMS_CURRENCY_ID, value) var total_gold_acquired: BigNumber: get: return _get_total_currency_ref(GOLD_CURRENCY_ID) set(value): _set_total_currency(GOLD_CURRENCY_ID, value) var total_gems_acquired: BigNumber: get: return _get_total_currency_ref(GEMS_CURRENCY_ID) set(value): _set_total_currency(GEMS_CURRENCY_ID, value) var generator_states: Dictionary = {} var last_save_time: int = 0 # Unix timestamp 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 CURRENCIES_KEY: String = "currencies" const CURRENT_KEY: String = "current" const TOTAL_KEY: String = "total" func _ready() -> void: _initialize_currency_catalog() _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]: 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_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: 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: 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 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) # ========================================== # PERSISTENCE (Save/Load) # ========================================== func save_game() -> void: var save_data = { CURRENCIES_KEY: _serialize_currency_balances(), # Keep legacy fields for compatibility with older builds. "gold": gold.serialize(), "gems": gems.serialize(), "total_gold_acquired": total_gold_acquired.serialize(), "total_gems_acquired": total_gems_acquired.serialize(), "generator_states": _serialize_generator_states(), "last_save_time": 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, {})) else: _load_legacy_currency_balances(parsed_data) generator_states = _deserialize_generator_states(parsed_data.get("generator_states", {})) last_save_time = parsed_data.get("last_save_time", Time.get_unix_time_from_system()) func _load_legacy_currency_balances(parsed_data: Dictionary) -> void: var loaded_gold: BigNumber = BigNumber.deserialize(parsed_data.get("gold", {})) var loaded_gems: BigNumber = BigNumber.deserialize(parsed_data.get("gems", {})) _set_current_currency(GOLD_CURRENCY_ID, loaded_gold) _set_current_currency(GEMS_CURRENCY_ID, loaded_gems) _set_total_currency(GOLD_CURRENCY_ID, _deserialize_total_currency(parsed_data.get("total_gold_acquired", null), loaded_gold)) _set_total_currency(GEMS_CURRENCY_ID, _deserialize_total_currency(parsed_data.get("total_gems_acquired", null), loaded_gems)) 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 raw_id in _currency_by_id.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) return ids func _initialize_currency_catalog() -> void: _currency_by_id.clear() for currency in DEFAULT_CURRENCIES: if currency == null: continue var currency_id: StringName = get_currency_id(currency) if currency_id == &"": push_warning("Skipping currency with missing id in DEFAULT_CURRENCIES.") continue _currency_by_id[currency_id] = currency func _initialize_currency_maps() -> void: for currency in DEFAULT_CURRENCIES: var currency_id: StringName = get_currency_id(currency) 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 _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() 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 _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_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