diff --git a/resources/player_weapon_resources/spread.tres b/resources/player_weapon_resources/spread.tres index bb4794c..dc6b432 100644 --- a/resources/player_weapon_resources/spread.tres +++ b/resources/player_weapon_resources/spread.tres @@ -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" diff --git a/resources/player_weapon_resources/stock.tres b/resources/player_weapon_resources/stock.tres index 79f8f83..41c3bb3 100644 --- a/resources/player_weapon_resources/stock.tres +++ b/resources/player_weapon_resources/stock.tres @@ -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" diff --git a/resources/player_weapon_resources/tri.tres b/resources/player_weapon_resources/tri.tres index c16b614..d13bcc6 100644 --- a/resources/player_weapon_resources/tri.tres +++ b/resources/player_weapon_resources/tri.tres @@ -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" diff --git a/resources/player_weapon_resources/vanguard+.tres b/resources/player_weapon_resources/vanguard+.tres index 1e62887..b8b46ab 100644 --- a/resources/player_weapon_resources/vanguard+.tres +++ b/resources/player_weapon_resources/vanguard+.tres @@ -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" diff --git a/resources/player_weapon_resources/vanguard.tres b/resources/player_weapon_resources/vanguard.tres index 006c9a5..0970935 100644 --- a/resources/player_weapon_resources/vanguard.tres +++ b/resources/player_weapon_resources/vanguard.tres @@ -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" diff --git a/scenes/player.tscn b/scenes/player.tscn index c240cfb..a39f299 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -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" diff --git a/scripts/notification_display.gd b/scripts/notification_display.gd index 4dbbc48..b401cc4 100644 --- a/scripts/notification_display.gd +++ b/scripts/notification_display.gd @@ -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 diff --git a/scripts/shoot_component.gd b/scripts/shoot_component.gd index bf783c1..0fc9234 100644 --- a/scripts/shoot_component.gd +++ b/scripts/shoot_component.gd @@ -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 diff --git a/scripts/weapon_component.gd b/scripts/weapon_component.gd index 1442102..63a916b 100644 --- a/scripts/weapon_component.gd +++ b/scripts/weapon_component.gd @@ -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() diff --git a/scripts/weapon_shot.gd b/scripts/weapon_shot.gd index 8abc83d..a2f902a 100644 --- a/scripts/weapon_shot.gd +++ b/scripts/weapon_shot.gd @@ -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