Track gas bomb cooldowns per bomber

This commit is contained in:
Loïc Wisniewski 2026-08-15 10:12:33 +02:00
commit eb79efdafe
6 changed files with 193 additions and 49 deletions

View file

@ -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))

View file

@ -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)

View file

@ -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")

View file

@ -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()

View file

@ -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

View file

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