spaceship/vfx/GasBombEffect.gd

113 lines
4 KiB
GDScript3
Raw Normal View History

Garrison laser vessels and gas bombers, on a shared ability seam #105 shipped with interceptors as the only garrisonable vessel, and the reason was a design fault rather than a decision: firing the laser and the gas bomb lived in the player's fleet controllers, so a stationed laser would have held its position and never shot. The shot is now a thing in the world rather than something a controller draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam and ArtilleryShell already had on the enemy side - and they outlive the vessel that fired them. Above them sits one seam, on VesselCooldownFillComponent: ability_range(), ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a bomb are now the same shape of thing to whoever pulls the trigger, so the garrison AI never names an ability - the test asserts that literally, by reading the AI's own source. A third armed vessel needs to implement the seam and nothing else. Ammunition is the CALLER's business, deliberately: the fleet spends from its hold, a garrison from its outpost's store, and neither concern belongs in the component. A garrison with an empty magazine holds fire rather than falling back on the fleet's hold. Outpost storage: 50 gas per stationed bomber and 50 energy per stationed laser, off the VESSEL definitions, so an outpost never learns which vessel needs what. It is a capacity, not a stock - hub transporters fill it exactly as they fill a gas burner's buffer, because building_logistics_deficit now takes the larger of the definition's static buffer and the garrison's magazine. Every other resource is capped at zero: an outpost is not a depot, and ore parked at one has nothing to do there and no transporter coming for it. Also restores two properties the Godot editor dropped from GasBurnerBuildingDefinition.tres when it re-saved the file: collected_outputs and capabilities. Without the first, nothing ever collects the burner's energy - test_gas_burner caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
class_name GasBombEffect
extends Node2D
# The gas bomb itself: the flight, the blast and the picture of both, with
# nothing in it about who threw it.
#
# Lifted out of GasBombController for the same reason LaserBeamEffect was
# lifted out of LaserController - the shot was something only the FLEET could
# do, and a garrisoned bomber needs the same shot from a different trigger
# and a different magazine. Added to the tree root, self-animating,
# self-freeing, and it outlives the vessel that threw it.
signal struck(hit_count: int)
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
const AOE_RADIUS := 180.0
const PROJECTILE_SPEED := 720.0
const EXPLOSION_DURATION := 0.8
var target: Vector2 = Vector2.ZERO
var damage: int = 3
var trail: PackedVector2Array = PackedVector2Array()
var exploded: bool = false
var explosion_time: float = 0.0
var hit_count: int = 0
static func launch(world: Node, origin: Vector2, at: Vector2,
blast_damage: int = 3) -> GasBombEffect:
var effect := GasBombEffect.new()
effect.position = origin
effect.target = at
effect.damage = blast_damage
effect.trail = PackedVector2Array([origin])
world.add_child(effect)
return effect
func in_flight() -> bool:
return not exploded
func _process(delta: float) -> void:
if not exploded:
_advance_flight(delta)
queue_redraw()
return
explosion_time = maxf(explosion_time - delta, 0.0)
queue_redraw()
if explosion_time <= 0.0:
queue_free()
func _advance_flight(delta: float) -> void:
var distance: float = position.distance_to(target)
if distance <= PROJECTILE_SPEED * delta:
position = target
_explode()
return
position = position.move_toward(target, PROJECTILE_SPEED * delta)
trail.append(position)
while trail.size() > 12:
trail.remove_at(0)
func _explode() -> void:
exploded = true
explosion_time = EXPLOSION_DURATION
hit_count = 0
# Asteroids too - a blast that clears everything around it but leaves the
# rocks untouched would need explaining, and they take damage the same way.
Implement #160, #161, #162, #163, #164 and #165 #162 - a single tap bought a research node outright. One physical tap arrives TWICE: Godot emulates a mouse button from every touch AND a touch from every mouse button, both project settings are on, and the menu's dispatcher routes InputEventScreenTouch and InputEventMouseButton alike to the tree. The second arrival found the node already selected and took itself for the confirming tap. Measured, not reasoned about - one tap produced two picks and two confirms. What is ignored is specifically the TWIN: same frame, same place, arriving through the other input kind. Deduping on frame and position alone also swallowed two genuine taps in one frame, which the test caught. #164 - the build menu predicted a caption's height from the COLUMN WIDTH: wide column, assume one line. Right for most names and wrong for the long ones, so "Spacial Workshop" wrapped onto the price beneath it. Captions are now measured against the font that draws them, and prices sized from PriceTag.wanted_height(), which existed for exactly this and was not being asked. Prices are set before anything is measured, because the old order laid out first and priced afterwards. The ship lab: its buy button is gone and a second tap on an option buys it, the same rule as the research tree; the explanation drops into the room that freed; research points move up into the resource table, where everything else being spent already is; the second row of options is placed from the first row's measured bottom rather than a constant; and the vessel strip is spaced from the panel's own width, so the eighth vessel is no longer half off the right edge. #160 - the quest panel was drawn BEFORE the sticks and the pivot pair, so they sat on top of it. Moved after them, under the narration banner. Its title rule ran straight through the close button; the header band is now as tall as that button. #161 - the prologue widens the formation into a freight convoy by writing over GameState.formation, and nothing put it back. The player flew the convoy's 600-unit spread for the rest of the game - a shape nobody chose, which reads as the fleet having drifted apart. #163 - a quest to station a vessel at the outpost, after the one that asks for the outpost. Building one and crewing it are different acts, and an empty outpost holds nothing. #165 - the gas bomb's blast and a vessel arriving fast both damage a structure. The blast used to stop politely at anything the player owned, which made a bomb dropped on a besieged mothership a free way to sweep the attackers off it. Collisions are caught on ENTRY, not on the 0.7s strike tick: a vessel is only moving fast for the instant it arrives, and a ship sent in at 900 did no damage at all until this moved. Docking is free - the fleet sits on its own mothership constantly to bank ore - and garrison vessels are exempt outright, because they launch from their outpost and re-dock with it at speed. The suite caught that one: the ram rule was destroying the garrison as it launched. Suite 153/153. Four tests changed because what they pinned moved: the lab's buy button, the intro's formation, the research tree's tap delivery, and the build menu's own preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:27:44 +02:00
#
# #165: and STRUCTURES. The blast used to stop politely at anything the
# player owned, which made a bomb dropped on a besieged mothership a free
# way to clear the attackers off it. Everything inside the radius is
# inside the radius; where it lands is the decision the weapon asks for.
for target_node in get_tree().get_nodes_in_group("enemies") \
+ get_tree().get_nodes_in_group("asteroids") \
+ get_tree().get_nodes_in_group("player_buildings"):
if is_instance_valid(target_node) and target_node is Node2D \
and target_node.global_position.distance_to(target) <= AOE_RADIUS:
target_node.take_damage(damage)
Garrison laser vessels and gas bombers, on a shared ability seam #105 shipped with interceptors as the only garrisonable vessel, and the reason was a design fault rather than a decision: firing the laser and the gas bomb lived in the player's fleet controllers, so a stationed laser would have held its position and never shot. The shot is now a thing in the world rather than something a controller draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam and ArtilleryShell already had on the enemy side - and they outlive the vessel that fired them. Above them sits one seam, on VesselCooldownFillComponent: ability_range(), ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a bomb are now the same shape of thing to whoever pulls the trigger, so the garrison AI never names an ability - the test asserts that literally, by reading the AI's own source. A third armed vessel needs to implement the seam and nothing else. Ammunition is the CALLER's business, deliberately: the fleet spends from its hold, a garrison from its outpost's store, and neither concern belongs in the component. A garrison with an empty magazine holds fire rather than falling back on the fleet's hold. Outpost storage: 50 gas per stationed bomber and 50 energy per stationed laser, off the VESSEL definitions, so an outpost never learns which vessel needs what. It is a capacity, not a stock - hub transporters fill it exactly as they fill a gas burner's buffer, because building_logistics_deficit now takes the larger of the definition's static buffer and the garrison's magazine. Every other resource is capped at zero: an outpost is not a depot, and ore parked at one has nothing to do there and no transporter coming for it. Also restores two properties the Godot editor dropped from GasBurnerBuildingDefinition.tres when it re-saved the file: collected_outputs and capabilities. Without the first, nothing ever collects the burner's energy - test_gas_burner caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
hit_count += 1
struck.emit(hit_count)
func _draw() -> void:
# Drawn in this node's own space while it flies (it moves), and around the
# blast point once it lands (it does not).
if not exploded:
if trail.size() >= 2:
for i in range(1, trail.size()):
var alpha: float = float(i) / trail.size()
draw_line(trail[i - 1] - position, trail[i] - position,
Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true)
draw_circle(Vector2.ZERO, 12.0, Color(BOMB_COLOR, 0.35))
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 7.0, BOMB_COLOR, 1.0)
return
var progress: float = 1.0 - explosion_time / EXPLOSION_DURATION
var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
draw_circle(Vector2.ZERO, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16))
draw_ring(Vector2.ZERO, radius, BOMB_COLOR, 1.0 - progress)
for i in range(7):
var angle: float = TAU * float(i) / 7.0 + progress
var cloud_at: Vector2 = 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))
func draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void:
draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true)
draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true)
draw_arc(center, radius, 0.0, TAU, 64, Color(1.0, 0.86, 0.96, 0.9 * alpha), 2.0, true)