354 lines
11 KiB
GDScript
354 lines
11 KiB
GDScript
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)
|
|
|
|
## 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 per_cycle: float = data.production_per_cycle(owned, 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
|
|
|
|
## 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)
|
|
return BigNumber.new(data.click_mantissa, data.click_exponent)
|
|
|
|
## 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)
|
|
|
|
## 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)
|
|
|
|
## 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))
|
|
|
|
## 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())
|
|
_is_registered = 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
|
|
|
|
## 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:
|
|
print(_generator_id)
|
|
print(state)
|
|
visible = true
|
|
|