diff --git a/GasBombController.gd b/GasBombController.gd index 2accdd9..4a5b213 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -23,16 +23,20 @@ var aim_screen := Vector2.ZERO var aim_target := Vector2.ZERO var saved_zoom := 1.0 var restoring_zoom := false -var cooldown_left := 0.0 - -var projectile_active := false -var projectile_position := Vector2.ZERO -var projectile_target := Vector2.ZERO -var projectile_trail := PackedVector2Array() -var explosion_time := 0.0 -var explosion_position := Vector2.ZERO +var projectiles: Array = [] +var explosions: Array = [] var last_hit_count := 0 +var projectile_active: bool: + get: return not projectiles.is_empty() + +var explosion_time: float: + get: + var longest := 0.0 + for explosion in explosions: + longest = maxf(longest, float(explosion.time)) + return longest + func _ready() -> void: button.visible = false @@ -40,10 +44,9 @@ func _ready() -> void: func _process(delta: float) -> void: - var was_exploding := explosion_time > 0.0 + var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty() button.visible = fleet.has_capability("gas_bomber") - cooldown_left = maxf(cooldown_left - delta, 0.0) - button.enabled = cooldown_left <= 0.0 + button.enabled = _next_ready_bomber() != null if not button.visible and aiming: _cancel_aim() @@ -53,27 +56,33 @@ func _process(delta: float) -> void: if is_equal_approx(z, saved_zoom): restoring_zoom = false - if projectile_active: - var distance := projectile_position.distance_to(projectile_target) + for i in range(projectiles.size() - 1, -1, -1): + var projectile: Dictionary = projectiles[i] + var distance: float = (projectile.position as Vector2).distance_to(projectile.target) if distance <= PROJECTILE_SPEED * delta: - projectile_position = projectile_target - projectile_active = false - _explode(projectile_target) + projectile.position = projectile.target + _explode(projectile.target) + projectiles.remove_at(i) else: - projectile_position = projectile_position.move_toward(projectile_target, + projectile.position = (projectile.position as Vector2).move_toward(projectile.target, PROJECTILE_SPEED * delta) - projectile_trail.append(projectile_position) - while projectile_trail.size() > 12: - projectile_trail.remove_at(0) - if explosion_time > 0.0: - explosion_time = maxf(explosion_time - delta, 0.0) - if aiming or projectile_active or explosion_time > 0.0 or was_exploding: + var trail: PackedVector2Array = projectile.trail + trail.append(projectile.position) + while trail.size() > 12: + trail.remove_at(0) + projectile.trail = trail + for i in range(explosions.size() - 1, -1, -1): + var explosion: Dictionary = explosions[i] + explosion.time = maxf(float(explosion.time) - delta, 0.0) + if float(explosion.time) <= 0.0: + explosions.remove_at(i) + if aiming or not projectiles.is_empty() or not explosions.is_empty() or had_visuals: queue_redraw() func can_begin_aim() -> bool: return button.visible and button.enabled and not button._blocked_by_modal() \ - and not aiming and not projectile_active + and not aiming func handle_touch(event: InputEventScreenTouch) -> bool: @@ -143,21 +152,35 @@ func _cancel_aim() -> void: func _fire() -> void: - var bomber: Node2D = fleet.first_ship_with_capability("gas_bomber") + var bomber: Node2D = _next_ready_bomber() if bomber == null: _cancel_aim() return - projectile_active = true - projectile_position = bomber.global_position - projectile_target = aim_target - projectile_trail = PackedVector2Array([projectile_position]) - cooldown_left = COOLDOWN + var component: Node = bomber.get_component("gas_bomber") + if not component.consume_shot(COOLDOWN): + _cancel_aim() + return + projectiles.append({ + "position": bomber.global_position, + "target": aim_target, + "trail": PackedVector2Array([bomber.global_position]), + }) _finish_aim() + button.enabled = _next_ready_bomber() != null + + +func _next_ready_bomber() -> Node2D: + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var component: Node = ship.get_component("gas_bomber") + if component != null and component.is_ready(): + return ship + return null func _explode(at: Vector2) -> void: - explosion_position = at - explosion_time = 0.8 + explosions.append({"position": at, "time": 0.8}) last_hit_count = 0 for enemy in get_tree().get_nodes_in_group("enemies"): if is_instance_valid(enemy) and enemy is Node2D \ @@ -172,22 +195,23 @@ func _draw() -> void: NeonDraw.draw_glowing_line(self, origin, aim_target, TARGET_COLOR, 0.7, 0.8) _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) - if projectile_trail.size() >= 2 and projectile_active: - for i in range(1, projectile_trail.size()): - var alpha := float(i) / projectile_trail.size() - draw_line(projectile_trail[i - 1], projectile_trail[i], - Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true) - if projectile_active: - draw_circle(projectile_position, 12.0, Color(BOMB_COLOR, 0.35)) - NeonDraw.draw_glowing_circle(self, projectile_position, 7.0, BOMB_COLOR, 1.0) - if explosion_time > 0.0: - var progress := 1.0 - explosion_time / 0.8 + for projectile in projectiles: + var trail: PackedVector2Array = projectile.trail + if trail.size() >= 2: + for i in range(1, trail.size()): + var alpha := float(i) / trail.size() + draw_line(trail[i - 1], trail[i], + Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true) + draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35)) + NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0) + for explosion in explosions: + var progress := 1.0 - float(explosion.time) / 0.8 var radius := lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) - draw_circle(explosion_position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) - _draw_ring(explosion_position, radius, BOMB_COLOR, 1.0 - progress) + draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) + _draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress) for i in range(7): var angle := TAU * float(i) / 7.0 + progress - var cloud_at := explosion_position + Vector2(cos(angle), sin(angle)) * radius * 0.55 + var cloud_at: Vector2 = explosion.position + Vector2(cos(angle), sin(angle)) * radius * 0.55 draw_circle(cloud_at, 18.0 + 14.0 * progress, Color(BOMB_COLOR, (1.0 - progress) * 0.22)) diff --git a/VesselGasBomberComponent.gd b/VesselGasBomberComponent.gd index 2ab77cf..bdff8a5 100644 --- a/VesselGasBomberComponent.gd +++ b/VesselGasBomberComponent.gd @@ -1,3 +1,54 @@ -extends Node +class_name VesselGasBomberComponent +extends Node2D var capability_id := "gas_bomber" +var cooldown_duration := 5.0 +var cooldown_left := 0.0 + +const READY_COLOR := Color(1.0, 0.25, 0.78) +const COOLING_COLOR := Color(0.38, 0.16, 0.32) + + +func setup() -> void: + queue_redraw() + + +func is_ready() -> bool: + return cooldown_left <= 0.0 + + +func consume_shot(duration: float) -> bool: + if not is_ready(): + return false + cooldown_duration = maxf(duration, 0.001) + cooldown_left = cooldown_duration + queue_redraw() + return true + + +func tick(delta: float) -> void: + if cooldown_left <= 0.0: + return + cooldown_left = maxf(cooldown_left - delta, 0.0) + queue_redraw() + + +func cooldown_progress() -> float: + return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) + + +func _draw() -> void: + var parent_ship: Node = get_parent() + var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE + if is_ready(): + # Bright, complete halo: this bomber owns one available shot. + NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 31.0, READY_COLOR, 0.9, core) + draw_circle(Vector2.ZERO, 4.0, Color(READY_COLOR, 0.5)) + return + # A muted full ring says "not ready"; the bright arc fills clockwise so + # the individual bomber also communicates how close it is to rearming. + draw_arc(Vector2.ZERO, 31.0, 0.0, TAU, 48, Color(COOLING_COLOR, 0.65), 4.0, true) + var progress := cooldown_progress() + if progress > 0.001: + draw_arc(Vector2.ZERO, 31.0, -PI / 2.0, + -PI / 2.0 + TAU * progress, 48, Color(READY_COLOR, 0.92), 4.0, true) diff --git a/VesselGasBomberComponent.tscn b/VesselGasBomberComponent.tscn index 090a40d..6eaa4ad 100644 --- a/VesselGasBomberComponent.tscn +++ b/VesselGasBomberComponent.tscn @@ -2,5 +2,5 @@ [ext_resource type="Script" path="res://VesselGasBomberComponent.gd" id="1"] -[node name="GasBomberComponent" type="Node"] +[node name="GasBomberComponent" type="Node2D"] script = ExtResource("1") diff --git a/tests/_gas_bomb_aim_preview.gd b/tests/_gas_bomb_aim_preview.gd index a0ac6a0..18bb8dc 100644 --- a/tests/_gas_bomb_aim_preview.gd +++ b/tests/_gas_bomb_aim_preview.gd @@ -15,11 +15,14 @@ func _ready() -> void: main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) var fleet: Node = main.get_node("Fleet") - fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([ + fleet.spawn_ships(4, Vector2.ZERO, PackedStringArray([ GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, - GameState.VESSEL_GAS_BOMBER])) + GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_GAS_BOMBER])) var skill: GasBombController = main.get_node("GasBombController") skill._process(0.0) + # Show both per-ship states in one frame: the first bomber is rearming, + # while the second has the bright complete ready halo and supplies the aim. + fleet.ships[2].get_component("gas_bomber").consume_shot(GasBombController.COOLDOWN) main.get_node("Camera2D").zoom = Vector2(1.8, 1.8) var button: Control = main.get_node("UI/GasBombButton") var press := InputEventScreenTouch.new() diff --git a/tests/test_gas_bomber_cooldowns.gd b/tests/test_gas_bomber_cooldowns.gd new file mode 100644 index 0000000..47562ee --- /dev/null +++ b/tests/test_gas_bomber_cooldowns.gd @@ -0,0 +1,65 @@ +extends SceneTree + +var main: Node +var fleet: Node +var skill: GasBombController +var elapsed := 0.0 + + +func _initialize() -> void: + var state: Node = root.get_node("GameState") + state.buildings.clear() + state.extractors.clear() + state.transporters.clear() + state.vessel_levels["unlock_gas_bomber"] = 1 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + skill = main.get_node("GasBombController") + + +func _process(delta: float) -> bool: + elapsed += delta + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_GAS_BOMBER])) + skill._process(0.0) + var first: Node = fleet.ships[0].get_component("gas_bomber") + var second: Node = fleet.ships[1].get_component("gas_bomber") + if not first.is_ready() or not second.is_ready() or not skill.button.enabled: + return _fail("two fresh bombers did not each begin ready") + + skill.aim_target = Vector2(500, 0) + skill._fire() + skill._process(0.0) + if first.is_ready() or not second.is_ready() or not skill.button.enabled: + return _fail("the first shot disabled more than its firing bomber") + if skill.projectiles.size() != 1: + return _fail("the first bomber did not launch exactly one projectile") + + skill.aim_target = Vector2(-500, 0) + skill._fire() + skill._process(0.0) + if first.is_ready() or second.is_ready() or skill.button.enabled: + return _fail("the skill stayed available after both bombers fired") + if skill.projectiles.size() != 2: + return _fail("independent bombers could not have shots in flight together") + + # Rearming one bomber restores one available shot without touching the + # other bomber's cooldown. + first.tick(GasBombController.COOLDOWN) + skill._process(0.0) + if not first.is_ready() or second.is_ready() or not skill.button.enabled: + return _fail("one completed cooldown did not restore exactly one shot") + print("TEST_GAS_BOMBER_COOLDOWNS: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_GAS_BOMBER_COOLDOWNS: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_gas_bomber_cooldowns.gd.uid b/tests/test_gas_bomber_cooldowns.gd.uid new file mode 100644 index 0000000..d03a550 --- /dev/null +++ b/tests/test_gas_bomber_cooldowns.gd.uid @@ -0,0 +1 @@ +uid://cwo6ake5taias