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) ## Emitted when this generator unlock goal transitions to completed. signal goal_achieved(generator_id: StringName, goal_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 ## Reference to generator UI @export var info_generator_container: GeneratorPanel ## 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 ## Prevents re-entrant registration while GameState emits synchronous callbacks. var _is_registering: 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.currency_changed.connect(_on_currency_changed) GameState.generator_state_changed.connect(_on_generated_state_changed) _evaluate_generator_unlock_goal(false) ## 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 = get_effective_auto_run_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 purchase_currency_id: StringName = get_purchase_currency_id() if purchase_currency_id == &"": return false var cost: BigNumber = get_cost_for_amount(amount) var current: BigNumber = _get_currency_amount_by_id(purchase_currency_id) 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 purchase_currency_id: StringName = get_purchase_currency_id() if purchase_currency_id == &"": return false var safe_amount: int = maxi(amount, 1) var total_cost: BigNumber = get_cost_for_amount(safe_amount) if not _spend_currency_by_id(purchase_currency_id, total_cost): purchase_failed.emit(safe_amount, total_cost, _get_currency_amount_by_id(purchase_currency_id)) 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 purchase_currency_id: StringName = get_purchase_currency_id() if purchase_currency_id == &"": return 0 var available_currency: float = _big_number_to_float(_get_currency_amount_by_id(purchase_currency_id)) 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 GameState.get_buffs_for_generator(_generator_id) func get_buff_level(buff_id: StringName) -> int: _ensure_registered() return GameState.get_buff_level(buff_id) func is_buff_unlocked(buff_id: StringName) -> bool: _ensure_registered() return GameState.is_buff_unlocked(buff_id) func get_buff_cost(buff_id: StringName) -> BigNumber: var buff: GeneratorBuffData = GameState.get_buff(buff_id) if buff == null: return BigNumber.from_float(0.0) if not is_buff_unlocked(buff.id): 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 = GameState.get_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 = GameState.get_buff(buff_id) if buff == null: return false if not is_buff_unlocked(buff.id): 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 = GameState.get_buff(buff_id) if buff == null: return false if not GameState.is_buff_unlocked(buff.id): return false var current_level: int = GameState.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_buff_level(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 = GameState.get_buff(buff_id) if buff == null: return true if not GameState.is_buff_unlocked(buff.id): return false return not buff.can_purchase_next_level(GameState.get_buff_level(buff.id)) func get_automatic_production_multiplier() -> float: return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER) func get_effective_auto_run_multiplier() -> float: return run_multiplier * get_automatic_production_multiplier() * _get_prestige_multiplier() func reset_runtime_state_for_prestige() -> void: _is_registered = false _is_registering = false cycle_progress_seconds = 0.0 _remaining_click_cooldown_seconds = 0.0 _ensure_registered() _evaluate_generator_unlock_goal(false) #_evaluate_buff_unlock_goals() visible = is_available_to_player() 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 func _get_prestige_multiplier() -> float: var manager: PrestigeManager = get_node_or_null("/root/PrestigeManager") if manager == null: return 1.0 if not manager.get_total_multiplier(): return 1.0 var raw_multiplier: Variant = manager.get_total_multiplier() if raw_multiplier is float: return maxf(raw_multiplier, 0.0) if raw_multiplier is int: return maxf(float(raw_multiplier), 0.0) return 1.0 ## 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) func get_purchase_currency_id() -> StringName: var purchase_currency: Currency = _resolve_purchase_currency() if purchase_currency == null: return &"" return GameState.get_currency_id(purchase_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) func _get_currency_amount_by_id(currency_id: StringName) -> BigNumber: if currency_id == &"": return BigNumber.from_float(0.0) return GameState.get_currency_amount_by_id(currency_id) ## 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 _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_purchase_currency() -> Currency: if data == null: return null return data.purchase_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: return GameState.get_effective_multiplier(_generator_id, kind) 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 or _is_registering: return if _generator_id == &"": _generator_id = _resolve_generator_id() if _generator_id == &"": return _is_registering = true GameState.register_generator( _generator_id, _default_unlocked_state(), _default_available_state(), _default_owned_state() ) _register_buffs() _is_registered = true _is_registering = false 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 var buff_id: StringName = StringName(buff_id_text) GameState.register_buff(buff) if not GameState.is_buff_unlocked(buff_id): var persisted_level: int = GameState.get_buff_level(buff_id) var starts_unlocked: bool = buff.unlocked or persisted_level > 0 GameState.set_buff_unlocked(buff_id, starts_unlocked) _try_unlock_buff_from_goal(buff) func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void: GameState._try_unlock_buff_from_goal(buff) func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void: GameState.evaluate_all_goals() _evaluate_generator_unlock_goal(false) func try_unlock_from_goal() -> bool: return _evaluate_generator_unlock_goal(true) func _evaluate_generator_unlock_goal(allow_manual_unlock: bool) -> bool: if data == null: return false if is_available_to_player(): return false if not data.has_unlock_goal(): return false if not data.unlocks_automatically_from_goal() and not allow_manual_unlock: return false var goal_id: StringName = data.get_unlock_goal_id() if goal_id.is_empty() or not GameState.is_goal_completed(goal_id): return false GameState.set_generator_unlocked(_generator_id, true) GameState.set_generator_available(_generator_id, true) goal_achieved.emit(_generator_id, goal_id) return true ## 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 func _default_owned_state() -> int: if data == null: return 0 return maxi(data.initial_owned, 0) ## Area2D callback: pointer entered generator interaction region. func _on_area_2d_mouse_entered() -> void: _mouse_entered = true info_generator_container.visible = true ## Area2D callback: pointer exited generator interaction region. func _on_area_2d_mouse_exited() -> void: _mouse_entered = false info_generator_container.visible = false func _on_generated_state_changed(generator_id: StringName, _state: Dictionary) -> void: if generator_id == _generator_id: visible = is_available_to_player()