Add click interaction

This commit is contained in:
Michele Rossi
2026-03-14 00:32:12 +01:00
parent 797467ea47
commit 23d1a79a9a
5 changed files with 138 additions and 2 deletions

View File

@@ -1,24 +1,53 @@
class_name CurrencyGenerator class_name CurrencyGenerator
extends Node extends Node
## 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) 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) 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) 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 const HUGE_COST_EXPONENT: int = 1000000
## Currency target updated by this generator.
@export var currency: GameState.CurrencyType = GameState.CurrencyType.gold @export var currency: GameState.CurrencyType = GameState.CurrencyType.gold
## Data resource containing balancing values and defaults.
@export var data: CurrencyGeneratorData @export var data: CurrencyGeneratorData
## Enables per-cycle automatic production when true.
@export var is_automatic: bool = 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 @export var press_buys_generator: bool = true
## Extra multiplier applied to automatic production output.
@export var run_multiplier: float = 1.0 @export var run_multiplier: float = 1.0
## Fallback unlocked state when `data` is not assigned.
@export var starts_unlocked: bool = true @export var starts_unlocked: bool = true
## Fallback availability state when `data` is not assigned.
@export var starts_available: bool = true @export var starts_available: bool = true
## Manual click fallback when no data is assigned. ## Manual click reward fallback when no data is assigned.
@export var click_mantissa: float = 1.0 @export var click_mantissa: float = 1.0
## Exponent for fallback click reward when no data is assigned.
@export var click_exponent: int = 0 @export var click_exponent: int = 0
## Cooldown between click/hover grants when no data is assigned.
@export var click_cooldown_seconds: float = 0.2
## If true, hovering grants click reward every cooldown without pressing.
@export var grants_click_while_hovering: bool = false
## 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: var owned: int:
get: get:
_ensure_registered() _ensure_registered()
@@ -27,6 +56,7 @@ var owned: int:
_ensure_registered() _ensure_registered()
GameState.set_generator_owned(_generator_id, value) GameState.set_generator_owned(_generator_id, value)
## Lifetime purchased generators (does not decrease if ownership drops).
var purchased_count: int: var purchased_count: int:
get: get:
_ensure_registered() _ensure_registered()
@@ -35,6 +65,7 @@ var purchased_count: int:
_ensure_registered() _ensure_registered()
GameState.set_generator_purchased_count(_generator_id, value) GameState.set_generator_purchased_count(_generator_id, value)
## Whether this generator is unlocked for the player.
var is_unlocked: bool: var is_unlocked: bool:
get: get:
_ensure_registered() _ensure_registered()
@@ -43,6 +74,7 @@ var is_unlocked: bool:
_ensure_registered() _ensure_registered()
GameState.set_generator_unlocked(_generator_id, value) GameState.set_generator_unlocked(_generator_id, value)
## Whether this unlocked generator is currently visible/usable to the player.
var is_available: bool: var is_available: bool:
get: get:
_ensure_registered() _ensure_registered()
@@ -51,16 +83,25 @@ var is_available: bool:
_ensure_registered() _ensure_registered()
GameState.set_generator_available(_generator_id, value) GameState.set_generator_available(_generator_id, value)
## Accumulated elapsed time toward the next automatic production cycle.
var cycle_progress_seconds: float = 0.0 var cycle_progress_seconds: float = 0.0
## Stable id used to store/read generator state in GameState.
var _generator_id: StringName = &"" var _generator_id: StringName = &""
## Prevents duplicate state registration in GameState.
var _is_registered: bool = false var _is_registered: bool = false
## Resolves and registers this generator state before gameplay updates.
func _ready() -> void: func _ready() -> void:
_generator_id = _resolve_generator_id() _generator_id = _resolve_generator_id()
_ensure_registered() _ensure_registered()
cycle_progress_seconds = 0.0 cycle_progress_seconds = 0.0
## Updates click cooldown/click grants and runs automatic production cycles.
func _process(delta: float) -> void: 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: if not is_automatic:
return return
if not is_available_to_player(): if not is_available_to_player():
@@ -77,6 +118,7 @@ func _process(delta: float) -> void:
cycle_progress_seconds -= cycle_seconds * float(completed_cycles) cycle_progress_seconds -= cycle_seconds * float(completed_cycles)
_grant_cycle_income(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: func _grant_cycle_income(cycle_count: int) -> void:
if data == null or cycle_count <= 0: if data == null or cycle_count <= 0:
return return
@@ -89,9 +131,11 @@ func _grant_cycle_income(cycle_count: int) -> void:
_add_currency(produced) _add_currency(produced)
production_tick.emit(produced, cycle_count, owned) production_tick.emit(produced, cycle_count, owned)
## Convenience helper for the next single-unit purchase cost.
func get_next_cost() -> BigNumber: func get_next_cost() -> BigNumber:
return get_cost_for_amount(1) 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: func get_cost_for_amount(amount: int = 1) -> BigNumber:
if data == null: if data == null:
return BigNumber.from_float(0.0) return BigNumber.from_float(0.0)
@@ -100,6 +144,7 @@ func get_cost_for_amount(amount: int = 1) -> BigNumber:
var raw_cost: float = data.cost_for_amount(owned, safe_amount) var raw_cost: float = data.cost_for_amount(owned, safe_amount)
return _float_to_big_number(raw_cost) return _float_to_big_number(raw_cost)
## Checks whether enough target currency is available for `amount` units.
func can_buy(amount: int = 1) -> bool: func can_buy(amount: int = 1) -> bool:
if not is_available_to_player(): if not is_available_to_player():
return false return false
@@ -108,6 +153,7 @@ func can_buy(amount: int = 1) -> bool:
var current: BigNumber = _get_currency_amount() var current: BigNumber = _get_currency_amount()
return current.is_greater_than(cost) or current.is_equal_to(cost) 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: func buy(amount: int = 1) -> bool:
if data == null: if data == null:
return false return false
@@ -125,6 +171,7 @@ func buy(amount: int = 1) -> bool:
purchase_completed.emit(safe_amount, owned, purchased_count, total_cost) purchase_completed.emit(safe_amount, owned, purchased_count, total_cost)
return true return true
## Computes and buys the maximum affordable units with current currency.
func buy_max() -> int: func buy_max() -> int:
if data == null: if data == null:
return 0 return 0
@@ -152,6 +199,7 @@ func buy_max() -> int:
return 0 return 0
return best if buy(best) else 0 return best if buy(best) else 0
## Handles external "pressed" interaction: buy if configured, otherwise click-grant.
func _on_pressed() -> void: func _on_pressed() -> void:
if not is_available_to_player(): if not is_available_to_player():
return return
@@ -160,18 +208,59 @@ func _on_pressed() -> void:
buy(1) buy(1)
return return
_add_currency(BigNumber.new(click_mantissa, click_exponent)) _try_grant_click_currency()
## Adds currency directly through the configured currency target.
func grant_currency(amount: BigNumber) -> void: func grant_currency(amount: BigNumber) -> void:
_add_currency(amount) _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 _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, with exported fallback when data is missing.
func _get_click_value() -> BigNumber:
if data != null:
return BigNumber.new(data.click_mantissa, data.click_exponent)
return BigNumber.new(click_mantissa, click_exponent)
## Resolves click cooldown from data, with exported fallback when data is missing.
func _get_click_cooldown_seconds() -> float:
if data != null:
return maxf(data.click_cooldown_seconds, 0.0)
return maxf(click_cooldown_seconds, 0.0)
## Resolves hover-only click-grant mode from data, with exported fallback.
func _grants_click_while_hovering() -> bool:
if data != null:
return data.grants_click_while_hovering
return grants_click_while_hovering
## Returns this generator's resolved state id.
func get_generator_id() -> StringName: func get_generator_id() -> StringName:
_ensure_registered() _ensure_registered()
return _generator_id return _generator_id
## True when the generator is both unlocked and available.
func is_available_to_player() -> bool: func is_available_to_player() -> bool:
return is_unlocked and is_available return is_unlocked and is_available
## Routes positive/negative currency deltas to the configured currency bucket.
func _add_currency(amount: BigNumber) -> void: func _add_currency(amount: BigNumber) -> void:
match currency: match currency:
GameState.CurrencyType.gold: GameState.CurrencyType.gold:
@@ -181,6 +270,7 @@ func _add_currency(amount: BigNumber) -> void:
_: _:
push_error("Unknown currency type in CurrencyGenerator._add_currency") push_error("Unknown currency type in CurrencyGenerator._add_currency")
## Attempts to spend target currency; returns false when insufficient.
func _spend_currency(cost: BigNumber) -> bool: func _spend_currency(cost: BigNumber) -> bool:
match currency: match currency:
GameState.CurrencyType.gold: GameState.CurrencyType.gold:
@@ -191,6 +281,7 @@ func _spend_currency(cost: BigNumber) -> bool:
push_error("Unknown currency type in CurrencyGenerator._spend_currency") push_error("Unknown currency type in CurrencyGenerator._spend_currency")
return false return false
## Returns the current amount for the configured currency type.
func _get_currency_amount() -> BigNumber: func _get_currency_amount() -> BigNumber:
match currency: match currency:
GameState.CurrencyType.gold: GameState.CurrencyType.gold:
@@ -200,6 +291,7 @@ func _get_currency_amount() -> BigNumber:
_: _:
return BigNumber.from_float(0.0) return BigNumber.from_float(0.0)
## Safely converts finite float values into BigNumber costs.
func _float_to_big_number(value: float) -> BigNumber: func _float_to_big_number(value: float) -> BigNumber:
if value <= 0.0: if value <= 0.0:
return BigNumber.from_float(0.0) return BigNumber.from_float(0.0)
@@ -207,6 +299,7 @@ func _float_to_big_number(value: float) -> BigNumber:
return BigNumber.new(1.0, HUGE_COST_EXPONENT) return BigNumber.new(1.0, HUGE_COST_EXPONENT)
return BigNumber.from_float(value) return BigNumber.from_float(value)
## Converts BigNumber to float for approximate estimate math.
func _big_number_to_float(value: BigNumber) -> float: func _big_number_to_float(value: BigNumber) -> float:
if value.mantissa <= 0.0: if value.mantissa <= 0.0:
return 0.0 return 0.0
@@ -216,6 +309,7 @@ func _big_number_to_float(value: BigNumber) -> float:
return 0.0 return 0.0
return value.mantissa * pow(10.0, float(value.exponent)) 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: func _resolve_generator_id() -> StringName:
if data != null: if data != null:
var data_id: String = String(data.id) var data_id: String = String(data.id)
@@ -227,6 +321,7 @@ func _resolve_generator_id() -> StringName:
return &"generator" return &"generator"
## Ensures this generator has a state entry in GameState.
func _ensure_registered() -> void: func _ensure_registered() -> void:
if _is_registered: if _is_registered:
return return
@@ -239,12 +334,22 @@ func _ensure_registered() -> void:
GameState.register_generator(_generator_id, _default_unlocked_state(), _default_available_state()) GameState.register_generator(_generator_id, _default_unlocked_state(), _default_available_state())
_is_registered = true _is_registered = true
## Default unlocked state used at registration time.
func _default_unlocked_state() -> bool: func _default_unlocked_state() -> bool:
if data != null: if data != null:
return data.starts_unlocked return data.starts_unlocked
return starts_unlocked return starts_unlocked
## Default available state used at registration time.
func _default_available_state() -> bool: func _default_available_state() -> bool:
if data != null: if data != null:
return data.starts_available return data.starts_available
return starts_available return 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

View File

@@ -23,6 +23,12 @@ extends Resource
## Example: 0.05 means each purchased unit adds +0.05% to output. ## Example: 0.05 means each purchased unit adds +0.05% to output.
@export var purchased_boost_percent: float = 0.0 @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
@export var grants_click_while_hovering: bool = false
## Returns cost of next generator ## Returns cost of next generator
func cost_next(owned: int) -> float: func cost_next(owned: int) -> float:
return cost_for_amount(owned, 1) return cost_for_amount(owned, 1)

View File

@@ -8,4 +8,5 @@ id = &"Gold"
name = "Gold Mine" name = "Gold Mine"
initial_time = 0.6 initial_time = 0.6
initial_productivity = 1.67 initial_productivity = 1.67
grants_click_while_hovering = true
metadata/_custom_type_script = "uid://b00tqsuhxdy0d" metadata/_custom_type_script = "uid://b00tqsuhxdy0d"

View File

@@ -3,15 +3,28 @@
[ext_resource type="Script" uid="uid://dtbxopw6ulhl8" path="res://generator/currency_generator.gd" id="1_pl17p"] [ext_resource type="Script" uid="uid://dtbxopw6ulhl8" path="res://generator/currency_generator.gd" id="1_pl17p"]
[ext_resource type="Resource" uid="uid://co0mcc2kvcpo5" path="res://generator/primary_generator.tres" id="2_x5lt1"] [ext_resource type="Resource" uid="uid://co0mcc2kvcpo5" path="res://generator/primary_generator.tres" id="2_x5lt1"]
[ext_resource type="Resource" uid="uid://04pmc034qupd" path="res://generator/secondary_generator.tres" id="3_dc82g"] [ext_resource type="Resource" uid="uid://04pmc034qupd" path="res://generator/secondary_generator.tres" id="3_dc82g"]
[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="3_mhw8n"]
[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="3_pw2a0"] [ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="3_pw2a0"]
[ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="3_x5lt1"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="3_x5lt1"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_y5m1q"]
size = Vector2(128, 128)
[node name="GeneratorMuseum" type="Node2D" unique_id=1219373683] [node name="GeneratorMuseum" type="Node2D" unique_id=1219373683]
[node name="GoldGenerator" type="Node2D" parent="." unique_id=964187690] [node name="GoldGenerator" type="Node2D" parent="." unique_id=964187690]
position = Vector2(566, 455)
script = ExtResource("1_pl17p") script = ExtResource("1_pl17p")
data = ExtResource("2_x5lt1") data = ExtResource("2_x5lt1")
[node name="Sprite2D" type="Sprite2D" parent="GoldGenerator" unique_id=381586216]
texture = ExtResource("3_mhw8n")
[node name="Area2D" type="Area2D" parent="GoldGenerator" unique_id=2084873356]
[node name="CollisionShape2D" type="CollisionShape2D" parent="GoldGenerator/Area2D" unique_id=726858258]
shape = SubResource("RectangleShape2D_y5m1q")
[node name="GemsGenerator" type="Node2D" parent="." unique_id=931294956] [node name="GemsGenerator" type="Node2D" parent="." unique_id=931294956]
script = ExtResource("1_pl17p") script = ExtResource("1_pl17p")
currency = 1 currency = 1
@@ -52,3 +65,6 @@ offset_top = 215.0
offset_right = 247.0 offset_right = 247.0
offset_bottom = 380.0 offset_bottom = 380.0
_generator = NodePath("../../GemsGenerator") _generator = NodePath("../../GemsGenerator")
[connection signal="mouse_entered" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_entered"]
[connection signal="mouse_exited" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_exited"]

View File

@@ -18,6 +18,14 @@ config/icon="res://icon.svg"
GameState="*uid://d2j7tvlgxr2jp" GameState="*uid://d2j7tvlgxr2jp"
[input]
click={
"deadzone": 1.0,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
[physics] [physics]
3d/physics_engine="Jolt Physics" 3d/physics_engine="Jolt Physics"