Set up framework for more advance pickup behaviours.

This commit is contained in:
Henry Faber 2026-07-02 15:44:23 +01:00
parent a0df0be862
commit 935b61bd3b
16 changed files with 485 additions and 20 deletions

View file

@ -0,0 +1,132 @@
class_name PickupBehaviorController extends Area2D
## Controls visual movement (bob, shake) and drift of a pickup.
## Attach to the root Area2D node of your pickup scene.
@export var behavior: PickupBehavior
@export var drift_behavior: PickupDriftBehavior
var _icon_node: TextureRect
var _spawn_position: Vector2 = Vector2.ZERO
func _ready() -> void:
# Find the runtime-added icon node.
for child in get_children():
if child is TextureRect:
_icon_node = child as TextureRect
break
# Capture the initial spawn position.
_spawn_position = global_position
if behavior != null:
behavior.start()
func _process(delta: float) -> void:
if behavior == null or drift_behavior == null:
return
var alpha: float = behavior.process(delta)
if _icon_node != null:
_icon_node.visible = alpha > 0.01
if behavior != null and behavior.fade_in_duration > 0 and _icon_node is CanvasItem:
_icon_node.modulate.a = alpha
var offset := _compute_animation_offset()
var drift_offset := _compute_drift_offset()
position = _spawn_position + Vector2(drift_offset.x, drift_offset.y)
if _icon_node != null:
_icon_node.position = offset
func _compute_animation_offset() -> Vector2:
# Returns the bob + shake offset applied to the icon node.
var result := Vector2.ZERO
if behavior == null:
return result
# Bob (vertical oscillation).
if behavior.bob_enabled:
var bob_angle := _elapsed_time() * TAU * behavior.bob_frequency
result.y += sin(bob_angle) * behavior.bob_amplitude
# Shake (subtle jitter).
if behavior.shake_enabled:
var shake_angle := _elapsed_time() * TAU * behavior.shake_frequency
result.x += sin(shake_angle) * behavior.shake_amplitude
return result
func _compute_drift_offset() -> Vector2:
# Returns the global drift position offset from initial spawn position.
if drift_behavior == null:
return Vector2.ZERO
match drift_behavior.drift_type:
PickupDriftBehavior.DriftType.STATIONARY:
return Vector2.ZERO
PickupDriftBehavior.DriftType.SINE_WAVE:
var t := _elapsed_time() * drift_behavior.wave_frequency
var travel_speed: float = drift_behavior.drift_speed
if drift_behavior.reverse_direction:
travel_speed *= -1.0
var offset := Vector2.ZERO
if drift_behavior.drift_along_x:
# Travels along X; waves on Y.
offset.x = travel_speed * _elapsed_time()
offset.y = sin(t + drift_behavior.phase_offset) * drift_behavior.wave_amplitude
else:
# Travels along Y; waves on X.
offset.y = travel_speed * _elapsed_time()
offset.x = sin(t + drift_behavior.phase_offset) * drift_behavior.wave_amplitude
return offset
PickupDriftBehavior.DriftType.LEAF_FALL:
var leaf_t := _elapsed_time() * drift_behavior.wave_frequency
var travel_speed: float = drift_behavior.fall_speed
if drift_behavior.reverse_direction:
travel_speed *= -1.0
var offset := Vector2.ZERO
if drift_behavior.drift_along_x:
# Travels along X; waves on Y.
offset.x = travel_speed * _elapsed_time()
offset.y = sin(leaf_t + drift_behavior.phase_offset) * drift_behavior.wave_amplitude
else:
# Travels along Y; waves on X.
offset.y = travel_speed * _elapsed_time()
offset.x = sin(leaf_t + drift_behavior.phase_offset) * drift_behavior.wave_amplitude
return offset
PickupDriftBehavior.DriftType.DIAGONAL:
var dir: float = 1.0 if drift_behavior.diagonal_forward else -1.0
if drift_behavior.reverse_direction:
dir *= -1.0
var offset := Vector2.ZERO
if drift_behavior.drift_along_x:
# Travels along X; secondary on Y.
offset.x = dir * drift_behavior.drift_speed * _elapsed_time()
offset.y = dir * (drift_behavior.drift_speed * 0.5) * _elapsed_time()
else:
# Travels along Y; secondary on X.
offset.y = dir * drift_behavior.drift_speed * _elapsed_time()
offset.x = dir * (drift_behavior.drift_speed * 0.5) * _elapsed_time()
return offset
return Vector2.ZERO
func _elapsed_time() -> float:
if behavior == null:
return 0.0
return behavior._elapsed

View file

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

View file

@ -9,18 +9,44 @@ class_name PowerUpSpawner extends Node2D
@onready var screensize: Vector2 = get_viewport().content_scale_size
@onready var spawn_timer: Timer = $SpawnTimer
# Optional behavior resources to assign randomly on spawn.
@export var pickup_behaviors: Array[PickupBehavior] = []
# All weapon resources to spawn as pickups.
# Populate in the editor with your weapon_shot resources.
@export var all_weapon_shots: Array[WeaponShot] = []
# Optional drift behaviors to assign randomly on spawn.
# Leave empty to use whatever is baked into the pickup scene.
@export var drift_behaviors: Array[PickupDriftBehavior] = []
# How long a pickup can linger off-screen before being cleaned up.
@export var offscreen_timeout: float = 5.0
# How long to wait after a pickup is collected before spawning the next one.
@export var cooldown_after_collection: float = 5.0
var _pickups_spawned: int = 0
var _pickups_remaining: int = 0
# Tracks active pickups so we can check off-screen timeouts.
var _active_pickups: Array[Area2D] = []
# Cooldown state for post-collection pause.
var _cooldown_timer: Timer
var _in_cooldown: bool = false
func _ready() -> void:
if spawn_timer == null:
return
spawn_timer.wait_time = spawn_interval
# Create a one-shot cooldown timer.
_cooldown_timer = Timer.new()
_cooldown_timer.one_shot = true
_cooldown_timer.timeout.connect(_on_cooldown_finished)
add_child(_cooldown_timer)
func _on_spawn_timer_timeout() -> void:
if test_mode:
@ -29,6 +55,12 @@ func _on_spawn_timer_timeout() -> void:
_spawn_normal_pickup()
func _on_cooldown_finished() -> void:
_in_cooldown = false
if spawn_timer != null and test_mode:
spawn_timer.start()
func _spawn_normal_pickup() -> void:
# Spawn a single random pickup at a position near the player.
if all_weapon_shots.is_empty():
@ -59,26 +91,51 @@ func _spawn_normal_pickup() -> void:
func _spawn_test_pickups() -> void:
for weapon: WeaponShot in all_weapon_shots:
if weapon == null or weapon.shot_name.is_empty():
continue
# In test mode: pick ONE random weapon and spawn a single pickup.
if all_weapon_shots.is_empty():
return
var pickup := _spawn_pickup(weapon)
if not is_instance_valid(pickup):
continue # skipped — no valid icon
var chosen_weapon: WeaponShot = all_weapon_shots[randi() % all_weapon_shots.size()]
if chosen_weapon == null or chosen_weapon.shot_name.is_empty():
return
# Random position within viewport, with edge padding.
var margin: float = 15.0
var pickup := _spawn_pickup(chosen_weapon)
if not is_instance_valid(pickup):
return
# Random position within viewport, with edge padding.
var margin: float = 15.0
if _should_randomize_origin(pickup):
# Randomize the perpendicular axis so pickups don't all start at one edge.
var drift := _get_pickup_drift(pickup)
if drift != null:
var offset := Vector2.ZERO
if drift.drift_along_x:
# Travels along X; randomize Y origin.
offset.x = randf_range(margin, screensize.x - margin)
offset.y = 0.0
else:
# Travels along Y; randomize X origin.
offset.x = 0.0
offset.y = randf_range(margin, screensize.y - margin)
pickup.position = offset
else:
pickup.position = Vector2(
randf_range(margin, screensize.x - margin),
randf_range(margin, screensize.y - margin)
)
else:
pickup.position = Vector2(
randf_range(margin, screensize.x - margin),
randf_range(margin, screensize.y - margin)
)
add_child(pickup)
_pickups_spawned += 1
_pickups_remaining = _pickups_spawned
add_child(pickup)
_active_pickups.append(pickup)
_pickups_spawned += 1
_pickups_remaining = _pickups_spawned
# Pause the timer while pickups are on screen; resume when all gone.
# Pause the timer while pickup is on screen; resume when gone or expired.
if spawn_timer != null:
spawn_timer.stop()
@ -92,12 +149,16 @@ func pickup_collected() -> void:
var wc: WeaponComponent = level.get_node_or_null("WeaponComponent")
if wc != null:
wc.test_mode = test_mode
# Decrement counter; resume timer once all pickups are gone.
_pickups_remaining = max(0, _pickups_remaining - 1)
if _pickups_remaining <= 0:
if spawn_timer != null:
spawn_timer.start()
_pickups_spawned = 0
# Remove from active list and start cooldown.
_active_pickups.clear()
if spawn_timer != null:
spawn_timer.stop()
_start_cooldown()
func _process(delta: float) -> void:
if test_mode:
_update_active_pickups(delta)
func _spawn_pickup(weapon: WeaponShot) -> Area2D:
@ -123,4 +184,99 @@ func _spawn_pickup(weapon: WeaponShot) -> Area2D:
icon.size = Vector2(16, 16)
pickup.add_child(icon)
# Assign a random behavior for visual variety.
_apply_random_behavior(pickup)
# Assign a random drift behavior for visual variety.
_apply_random_drift(pickup)
return pickup
func _cleanup_pickup(pickup: Area2D) -> void:
# Remove from active list if present.
for i in _active_pickups.size():
if _active_pickups[i] == pickup:
_active_pickups.remove_at(i)
break
pickup.queue_free()
# Trigger cooldown/respawn when a pickup drifts off-screen.
_start_cooldown()
func _update_active_pickups(_delta: float) -> void:
# Check each active pickup for off-screen timeout.
var viewport_rect := get_viewport_rect().size
var margin: float = 50.0 # how far off-screen counts as "gone"
var to_remove: Array[Area2D] = []
for pickup in _active_pickups:
if not is_instance_valid(pickup):
continue
# Simple off-screen check: outside viewport + margin.
if (pickup.position.x < -margin
or pickup.position.x > viewport_rect.x + margin
or pickup.position.y < -margin
or pickup.position.y > viewport_rect.y + margin):
to_remove.append(pickup)
for bad_pickup in to_remove:
_cleanup_pickup(bad_pickup)
func _start_cooldown() -> void:
if offscreen_timeout <= 0 or cooldown_after_collection <= 0:
return
_in_cooldown = true
_cooldown_timer.wait_time = cooldown_after_collection
_cooldown_timer.start()
func _apply_random_behavior(pickup: Area2D) -> void:
if pickup_behaviors.is_empty():
return
var controller := pickup.get_node_or_null("PickupBehaviorController")
if controller == null:
return
var chosen: PickupBehavior = pickup_behaviors[randi() % pickup_behaviors.size()].duplicate() as PickupBehavior
if chosen != null:
controller.behavior = chosen
func _apply_random_drift(pickup: Area2D) -> void:
# Randomly assign a drift behavior to the spawned pickup instance.
if drift_behaviors.is_empty():
return
var controller := pickup.get_node_or_null("PickupBehaviorController")
if controller == null:
return
var chosen: PickupDriftBehavior = drift_behaviors[randi() % drift_behaviors.size()].duplicate() as PickupDriftBehavior
if chosen == null:
return
# Randomize phase offset for per-instance variety.
chosen.phase_offset = randf_range(0.0, TAU)
# Randomize axis (horizontal vs vertical travel).
chosen.drift_along_x = randf() > 0.5
# Randomize direction (forward vs reverse).
chosen.reverse_direction = randf() > 0.5
# Randomize perpendicular origin.
chosen.randomize_origin = randf() > 0.5
# When randomizing origin, zero the phase so the wave starts at center (0 offset).
if chosen.randomize_origin:
chosen.phase_offset = 0.0
controller.drift_behavior = chosen
func _get_pickup_drift(pickup: Area2D) -> PickupDriftBehavior:
var controller := pickup.get_node_or_null("PickupBehaviorController")
if controller == null:
return null
return controller.drift_behavior
func _should_randomize_origin(pickup: Area2D) -> bool:
var drift := _get_pickup_drift(pickup)
return drift != null and drift.randomize_origin