Updated cycle testing logic and various other behaviours for enhanced

system.
This commit is contained in:
Henry Faber 2026-06-29 22:12:28 +01:00
parent 1ac71cc104
commit 7ffb93963f
10 changed files with 237 additions and 31 deletions

View file

@ -15,4 +15,6 @@ horizontal_offset = 8.25
spread_angle = 25.0
stagger_offset = 0.5
stagger_animation = true
power_level = 1
force_replace_all = true
metadata/_custom_type_script = "uid://7n1itonn35fm"

View file

@ -12,4 +12,6 @@ damage = 2
speed = 650
horizontal_offset = 12.0
stagger_offset = 0.0
power_level = 1
force_replace_all = true
metadata/_custom_type_script = "uid://7n1itonn35fm"

View file

@ -14,4 +14,6 @@ projectiles = 3
horizontal_offset = 12.5
stagger_offset = 0.35
stagger_animation = true
power_level = 1
force_replace_all = true
metadata/_custom_type_script = "uid://7n1itonn35fm"

View file

@ -18,4 +18,6 @@ compensate_spawn_geometry = true
stagger_animation = true
follow_angle = true
groups = Array[ExtResource("2_qeb4b")]([ExtResource("3_qeb4b")])
power_level = 1
force_replace_all = true
metadata/_custom_type_script = "uid://7n1itonn35fm"

View file

@ -14,4 +14,6 @@ projectiles = 5
horizontal_offset = 8.25
stagger_offset = 0.5
stagger_animation = true
power_level = 1
force_replace_all = true
metadata/_custom_type_script = "uid://7n1itonn35fm"

View file

@ -10,12 +10,12 @@
[ext_resource type="Script" uid="uid://ylmao2ndp22y" path="res://scripts/weapon_component.gd" id="7_d2wvv"]
[ext_resource type="Resource" uid="uid://b75ae840k03dy" path="res://resources/player_weapon_resources/stock.tres" id="8_stock"]
[ext_resource type="Resource" uid="uid://bhc6aja38vyr" path="res://resources/player_weapon_resources/vanguard.tres" id="9_ur7pv"]
[ext_resource type="Script" uid="uid://znfh8unmdxkvv" path="res://scripts/weapon_fire_helper.gd" id="15_firehelper"]
[ext_resource type="Script" uid="uid://7n1itonn35fm" path="res://scripts/weapon_shot.gd" id="10_d2wvv"]
[ext_resource type="Resource" uid="uid://cck3gmnhu5agc" path="res://resources/player_weapon_resources/tri.tres" id="11_3v2ag"]
[ext_resource type="Script" uid="uid://65d3hbrpm21x" path="res://scripts/effects_component.gd" id="12_effcomp"]
[ext_resource type="Resource" uid="uid://ds53sryglmf27" path="res://resources/player_weapon_resources/spread.tres" id="13_f1ej7"]
[ext_resource type="Resource" uid="uid://c41aoyka7nfr8" path="res://resources/player_weapon_resources/vanguard+.tres" id="14_oprun"]
[ext_resource type="Script" uid="uid://bpcv2ojtyityc" path="res://scripts/weapon_fire_helper.gd" id="15_firehelper"]
[sub_resource type="AtlasTexture" id="AtlasTexture_tuyoq"]
atlas = ExtResource("5_qlg0r")
@ -133,7 +133,7 @@ position = Vector2(0, 3)
shape = SubResource("RectangleShape2D_tuyoq")
debug_color = Color(0, 1, 0, 0.41960785)
[node name="HitBox" type="CollisionShape2D" parent="." unique_id=485826453]
[node name="HitBox" type="CollisionShape2D" parent="." unique_id=484826453]
position = Vector2(0, 2)
shape = SubResource("RectangleShape2D_dqkch")
debug_color = Color(0.9843137, 0, 0, 0.80784315)
@ -164,7 +164,7 @@ script = ExtResource("6_y4r1p")
unique_name_in_owner = true
script = ExtResource("7_d2wvv")
weapon_data = ExtResource("8_stock")
available_weapons = Array[ExtResource("10_d2wvv")]([ExtResource("8_stock"), ExtResource("11_3v2ag"), ExtResource("9_ur7pv"), ExtResource("13_f1ej7"), ExtResource("14_oprun")])
available_weapons = Array[ExtResource("10_d2wvv")]([ExtResource("8_stock"), ExtResource("11_3v2ag"), ExtResource("13_f1ej7"), ExtResource("9_ur7pv"), ExtResource("14_oprun")])
weapon_change_flash = SubResource("Resource_jej6c")
metadata/_custom_type_script = "uid://ylmao2ndp22y"

View file

@ -33,6 +33,9 @@ func show_notification(weapon_name: String, player_node: Node2D, player_sprite_s
var icon_path = "res://graphics/" + weapon_name.to_lower() + ".png"
if ResourceLoader.exists(icon_path):
icon.texture = load(icon_path)
elif ResourceLoader.exists("res://graphics/stock.png"):
# Fallback to stock icon when no dedicated icon exists
icon.texture = load("res://graphics/stock.png")
else:
icon.texture = null

View file

@ -5,13 +5,24 @@ class_name ShootComponent extends Node
@onready var ship: Sprite2D = get_node("../Ship")
@onready var fire_helper: WeaponFireHelper = %WeaponFireHelper
func shoot():
var weapon_data = weapon_component.weapon_data
var current_time = Time.get_ticks_msec() / 1000.0
func shoot() -> void:
var active_weapons: Array[WeaponShot] = weapon_component.get_active_weapons()
if active_weapons.is_empty():
return
if player.travel > weapon_data.projectile_vertical_spacing:
_shoot_standard(weapon_data, current_time)
_shoot_grouped(weapon_data, current_time)
var current_time: float = Time.get_ticks_msec() / 1000.0
# Burst gating: find the minimum projectile_vertical_spacing among all
# active weapons. This is the most restrictive timing threshold.
var min_spacing: float = INF
for w: WeaponShot in active_weapons:
if w.projectile_vertical_spacing < min_spacing:
min_spacing = w.projectile_vertical_spacing
if player.travel > min_spacing:
for w: WeaponShot in active_weapons:
_shoot_standard(w, current_time)
_shoot_grouped(w, current_time)
# Reset travel after firing to prevent multiple bursts per tap
player.travel = 0.0
@ -26,12 +37,12 @@ func _get_spawn_position(weapon_data: WeaponShot) -> Vector2:
sprite_height = ship.texture.get_height()
# Top edge of the sprite in world space, then offset by origin.
var top_edge = ship.global_position.y - (sprite_height / 2.0)
var top_edge: float = ship.global_position.y - (sprite_height / 2.0)
return Vector2(ship.global_position.x, top_edge + weapon_data.origin)
# Standard projectile logic (delegates to WeaponFireHelper)
func _shoot_standard(weapon_data: WeaponShot, current_time: float):
func _shoot_standard(weapon_data: WeaponShot, current_time: float) -> void:
var total_projectiles: int = weapon_data.projectiles
if total_projectiles > 0:
@ -40,8 +51,8 @@ func _shoot_standard(weapon_data: WeaponShot, current_time: float):
get_tree().root.add_child(bullet)
# Delegate math to the helper
var spawn_position = _get_spawn_position(weapon_data)
var props = fire_helper.compute_standard_props(weapon_data, b, spawn_position, current_time)
var spawn_position: Vector2 = _get_spawn_position(weapon_data)
var props: Dictionary = fire_helper.compute_standard_props(weapon_data, b, spawn_position, current_time)
bullet.position = props.position
bullet.angle = props.angle
bullet.time_offset = props.time_offset
@ -56,15 +67,15 @@ func _shoot_standard(weapon_data: WeaponShot, current_time: float):
# Grouped projectile logic (delegates to WeaponFireHelper)
func _shoot_grouped(weapon_data: WeaponShot, current_time: float):
func _shoot_grouped(weapon_data: WeaponShot, current_time: float) -> void:
for group: WeaponProjectileGroup in weapon_data.groups.filter(func(g): return g != null):
for b in range(group.bullet_count):
var bullet := weapon_data.bullet_scene.instantiate() as Area2D
get_tree().root.add_child(bullet)
# Apply computed properties for the original bullet
var spawn_position = _get_spawn_position(weapon_data)
var props = fire_helper.compute_grouped_props(group, weapon_data, b, spawn_position, current_time, false)
var spawn_position: Vector2 = _get_spawn_position(weapon_data)
var props: Dictionary = fire_helper.compute_grouped_props(group, weapon_data, b, spawn_position, current_time, false)
bullet.position = props.position
bullet.angle = props.angle
bullet.time_offset = props.time_offset
@ -79,8 +90,8 @@ func _shoot_grouped(weapon_data: WeaponShot, current_time: float):
get_tree().root.add_child(mirror_bullet)
# Apply computed properties for the mirrored bullet
var mirror_spawn = _get_spawn_position(weapon_data)
var mirror_props = fire_helper.compute_grouped_props(group, weapon_data, b, mirror_spawn, current_time, true)
var mirror_spawn: Vector2 = _get_spawn_position(weapon_data)
var mirror_props: Dictionary = fire_helper.compute_grouped_props(group, weapon_data, b, mirror_spawn, current_time, true)
mirror_bullet.position = mirror_props.position
mirror_bullet.angle = mirror_props.angle
mirror_bullet.time_offset = mirror_props.time_offset

View file

@ -4,41 +4,184 @@ class_name WeaponComponent extends Node
@export var available_weapons: Array[WeaponShot] = []
@export var weapon_change_flash: BaseEffect = null
var _current_weapon_index: int = 0
var _current_weapon_index = 0
var _current_power_level = 0
var _cycled_weapon: WeaponShot = null
@onready var effects_component: EffectComponent = %EffectsComponent
@onready var effects_component: EffectComponent = get_node("EffectsComponent")
func _ready() -> void:
# Set the default weapon to be stock, i.e. index 1
if weapon_data and not available_weapons.is_empty():
for i in range(available_weapons.size()):
if available_weapons[i] == weapon_data:
_current_weapon_index = i
# Initialize the power level from the default weapon so
# shooting works immediately without needing to cycle.
_current_power_level = weapon_data.power_level
return
# If available_weapons was populated from the scene but no matching
# weapon_data was found (e.g. all weapons share the same power level),
# cap the power level to the minimum power level across all weapons.
# This ensures initial shooting works even when weapon_data is null or
# mismatches are found.
if not available_weapons.is_empty():
var min_level: int = 999
for w: WeaponShot in available_weapons:
if w != null and w.power_level < min_level:
min_level = w.power_level
_current_power_level = min_level
func _compute_active_weapons() -> Array[WeaponShot]:
# Filter available weapons by current power level.
var active: Array[WeaponShot] = []
for weapon: WeaponShot in available_weapons:
if weapon != null and weapon.power_level <= _current_power_level:
active.append(weapon)
return active
func get_active_weapons(power_level: int = -1) -> Array[WeaponShot]:
# Return all weapons active at the given power level.
#
# If power_level is negative, uses the internally tracked power level.
# var _level: int = power_level if power_level >= 0 else _current_power_level
# (unused - power_level parameter is ignored by design)
# If the cycled weapon is set, return it exclusively —
# cycling overrides all additive logic.
if _cycled_weapon != null:
return [_cycled_weapon]
var base: Array[WeaponShot] = _compute_active_weapons()
if not base.is_empty():
var additive: Array[WeaponShot] = base.filter(
func(w: WeaponShot): return not w.force_replace_all
)
if not additive.is_empty():
return additive
# If no non-force-replace weapons are active, return only the current
# default weapon (weapon_data). This ensures the starting weapon (stock)
# fires as a single shot rather than all force_replace_all weapons firing
# simultaneously.
if weapon_data != null and base.has(weapon_data):
return [weapon_data]
return base.filter(
func(w: WeaponShot): return w.force_replace_all
)
func _get_force_replace_weapons() -> Array[WeaponShot]:
# Return weapons that override all others (force_replace_all = true).
return _compute_active_weapons().filter(
func(w: WeaponShot): return w.force_replace_all
)
func collect_weapon(new_weapon: WeaponShot) -> void:
# Handle adding a weapon, applying replacement logic as needed.
#
# Replacement rules (applied in order):
# 1. force_replace_all: Discard all weapons, add only this one.
# Rule 2: replaces_at_same_level: Remove weapons at matching power level.
# 3. Normal: Add additive weapon (no removal).
#
# Sets the power level to this weapon's if higher than current.
# Emits weapon_changed with the new active set.
#
# Clear any active cycle — collecting a weapon during cycling restores
# normal additive behavior.
if _cycled_weapon != null:
reset_weapon_cycle()
# Rule 1: Force replace — discard everything, keep only this weapon
if new_weapon.force_replace_all:
available_weapons = [new_weapon]
_current_power_level = new_weapon.power_level
_update_data_from_active_weapons()
_emit_weapon_changed()
_trigger_change_flash()
return
# Rule 2: Replace if same level — remove weapons at matching power level
if new_weapon.replaces_at_same_level:
var was_replaced: bool = false
for i in range(available_weapons.size()):
if available_weapons[i] != null and \
available_weapons[i].replaces_at_same_level == false and \
available_weapons[i].power_level == new_weapon.power_level:
available_weapons.remove_at(i)
was_replaced = true
break # Only replace the highest (first match at this level)
if was_replaced:
# Also remove any other weapons with the same power_level that may
# have sneaked in via previous power level upgrades
var filtered: Array[WeaponShot] = []
for w: WeaponShot in available_weapons:
if w != null and w.power_level == new_weapon.power_level:
continue
filtered.append(w)
available_weapons = filtered
# Rule 3: Additive — just add it (no removal)
var already_has: bool = false
for w: WeaponShot in available_weapons:
if w != null and w.shot_name == new_weapon.shot_name:
already_has = true
break
if not already_has:
available_weapons.append(new_weapon)
# Update power level if this weapon is at a higher tier
if new_weapon.power_level > _current_power_level:
_current_power_level = new_weapon.power_level
_update_data_from_active_weapons()
_emit_weapon_changed()
_trigger_change_flash()
func _update_data_from_active_weapons() -> void:
# Update weapon_data to point to the highest-priority active weapon.
#
# This is called after any weapon collection or power level change to
# keep the legacy `weapon_data` property in sync for backward compatibility.
# Force_replace_all weapons take priority over additive weapons.
# Check for force_replace weapons first (they override everything)
var force_replaces: Array[WeaponShot] = _get_force_replace_weapons()
if not force_replaces.is_empty():
weapon_data = force_replaces[-1]
return
# Otherwise, point to the highest-priority additive weapon
var active: Array[WeaponShot] = get_active_weapons()
if not active.is_empty():
weapon_data = active[-1]
else:
weapon_data = null
func _emit_weapon_changed() -> void:
if weapon_data == null:
return
var ship: Sprite2D = get_parent().get_node("Ship")
var sprite_size = ship.get_rect().size
var origin_offset: float = weapon_data.origin if weapon_data else 0.0
var origin_offset: float = weapon_data.origin
EventBus.weapon_changed.emit(weapon_data.shot_name, get_parent(), sprite_size, origin_offset)
func get_bullet_scene() -> PackedScene:
return weapon_data.bullet_scene if weapon_data else null
func get_weapon_resource() -> Resource:
return weapon_data
func select_weapon_by_name(weapon_name: String) -> bool:
for i in range(available_weapons.size()):
if available_weapons[i].shot_name == weapon_name:
_current_weapon_index = i
weapon_data = available_weapons[i]
var candidate: WeaponShot = available_weapons[i]
if candidate != null and candidate.shot_name == weapon_name:
_cycled_weapon = candidate # override additive behavior
print("Switched to: ", weapon_name)
_emit_weapon_changed()
_trigger_change_flash()
return true
return false
@ -49,14 +192,36 @@ func _trigger_change_flash() -> void:
effects_component.apply_effect(weapon_change_flash)
func cycle_weapon() -> void: # Used for testing weapon cycling
func cycle_weapon() -> void: # Used for testing weapon cycling
if available_weapons.is_empty():
return
_current_weapon_index = (_current_weapon_index + 1) % available_weapons.size()
weapon_data = available_weapons[_current_weapon_index]
print("Switched to: ", weapon_data.shot_name)
var new_weapon: WeaponShot = available_weapons[_current_weapon_index]
if new_weapon == null:
return
print("Switched to: ", new_weapon.shot_name)
# Remove the previous weapon from the active set before engaging the next.
# Clearing _cycled_weapon forces get_active_weapons() to evaluate the
# additive state (which temporarily re-includes the old weapon). Then
# re-setting _cycled_weapon to the new weapon re-establishes the cycle
# override. This two-step process ensures the old weapon is fully removed
# from the active set and its power level doesn't leak into the new cycle.
_cycled_weapon = null # 1) Remove previous weapon
_cycled_weapon = new_weapon # 2) Engage next weapon
_current_power_level = new_weapon.power_level
# Update weapon_data to reflect the cycled weapon directly,
# so _emit_weapon_changed() reports the correct name and origin.
weapon_data = new_weapon
_emit_weapon_changed()
_trigger_change_flash()
func reset_weapon_cycle() -> void:
# Restore normal additive behavior after cycling.
_cycled_weapon = null
_update_data_from_active_weapons()
_emit_weapon_changed()
_trigger_change_flash()

View file

@ -82,3 +82,20 @@ extends Resource
# Array of projectile groups defining layered, multi-directional fire patterns.
@export var groups: Array[WeaponProjectileGroup] = []
@export_subgroup("Power & Composition")
# The power level at which this weapon becomes active.
# Weapons with power_level <= current power level are considered active.
@export_range(0, 20, 1) var power_level: int = 0
# If true, collecting this weapon removes any existing weapon whose
# power_level matches this weapon's power_level. Use this instead of
# force_replace_all for rank-up replacements (e.g. stock → tri → spread).
@export var replaces_at_same_level: bool = false
# If true, this weapon replaces ALL active weapons (ignores power_level).
# Used for rank-based transformations where the entire arsenal changes.
@export var force_replace_all: bool = false