spaceship/GasBombController.gd
Loïc Wisniewski 17d0cf7e66 Gas bombers consume gas from the fleet hold when firing (#60)
Shots cost GAS_PER_SHOT (5) from GameState.fleet_inventory, not the
bank - the fleet has to be carrying its own ammunition. The button's
enabled rule now also requires affordability, and a press on a ready-
but-unaffordable bomber still consumes the touch to explain itself via
a new status label (GasBombStatus, IconButton's own "greyed but still
pressable" convention) rather than either silently doing nothing or
consuming the bomber's cooldown for a shot that never fires.

Also documents a real, reproducible GDScript trap hit while writing
tests/test_gas_bomber_ammo.gd: a bare `ClassName.SOME_CONST` reference
anywhere in a -s entry test script can corrupt method resolution for
the WHOLE script, making an unrelated, earlier `some_node._process()`
call fail with "Nonexistent function '_process' in base 'Nil'" despite
the node being valid and the method existing (confirmed via
has_method()). Root cause unconfirmed; fixed by mirroring the constant
's value into a local const instead of referencing the class directly,
in all three gas-bomber tests.

tests/test_gas_bomber.gd and test_gas_bomber_cooldowns.gd updated to
stock fleet gas first, since shots are no longer free.
2026-08-16 18:54:44 +02:00

254 lines
8.3 KiB
GDScript

class_name GasBombController
extends Node2D
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
const MAX_RANGE := 950.0
const AOE_RADIUS := 180.0
const DRAG_RADIUS := 260.0
const AIM_DEADZONE := 22.0
const PROJECTILE_SPEED := 720.0
const DAMAGE := 3
const COOLDOWN := 5.0
# #60: ammunition from the fleet hold, not the bank - the fleet has to be
# carrying its own gas to fire.
const GAS_PER_SHOT: int = 5
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var button: IconButton = get_node("../UI/GasBombButton")
@onready var status: Label = get_node("../UI/GasBombStatus")
var status_message_until: int = 0
var aiming := false
var aim_touch := -1
var aim_start_screen := Vector2.ZERO
var aim_screen := Vector2.ZERO
var aim_target := Vector2.ZERO
var saved_zoom := 1.0
var restoring_zoom := false
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
status.text = ""
queue_redraw()
func _can_afford_shot() -> bool:
return GameState.fleet_resource(GameState.RESOURCE_GAS) >= GAS_PER_SHOT
func _say(message: String) -> void:
status.text = message
status_message_until = Time.get_ticks_msec() + 3000
func _process(delta: float) -> void:
var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty()
button.visible = fleet.has_capability("gas_bomber")
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()
if status.text != "" and Time.get_ticks_msec() >= status_message_until:
status.text = ""
if restoring_zoom and not aiming:
var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(z, z)
if is_equal_approx(z, saved_zoom):
restoring_zoom = false
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
_explode(projectile.target)
projectiles.remove_at(i)
else:
projectile.position = (projectile.position as Vector2).move_toward(projectile.target,
PROJECTILE_SPEED * delta)
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:
# Deliberately NOT button.enabled: an unaffordable shot still needs the
# press to reach here so it can explain itself (IconButton's own "greyed
# but still pressable" convention) rather than the touch silently
# falling through to whatever's behind it. A wholly unready fleet (no
# bomber at all) has nothing more useful to say than "greyed out".
return button.visible and not button._blocked_by_modal() \
and not aiming and _next_ready_bomber() != null
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position):
return false
if not _can_afford_shot():
_say("Need %d gas in the fleet hold to fire (have %d)" \
% [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)])
return true
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
var should_fire := aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE
if should_fire:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _update_aim() -> void:
var drag := aim_screen - aim_start_screen
var origin: Vector2 = fleet.get_centroid()
if drag.length() < 0.001:
aim_target = origin
else:
var distance := minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE
aim_target = origin + drag.normalized() * distance
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement := target - origin
var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + AOE_RADIUS)
var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + AOE_RADIUS)
var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var bomber: Node2D = _next_ready_bomber()
if bomber == null:
_cancel_aim()
return
if not _can_afford_shot():
_say("Need %d gas in the fleet hold to fire (have %d)" \
% [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)])
_cancel_aim()
return
var component: Node = bomber.get_component("gas_bomber")
if not component.consume_shot(COOLDOWN):
_cancel_aim()
return
GameState.withdraw_fleet_resource(GameState.RESOURCE_GAS, GAS_PER_SHOT)
projectiles.append({
"position": bomber.global_position,
"target": aim_target,
"trail": PackedVector2Array([bomber.global_position]),
})
_finish_aim()
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
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:
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 \
and enemy.global_position.distance_to(at) <= AOE_RADIUS:
enemy.take_damage(DAMAGE)
last_hit_count += 1
func _draw() -> void:
if aiming:
var origin: Vector2 = fleet.get_centroid()
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)
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)
for i in range(7):
var angle := TAU * float(i) / 7.0 + progress
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))
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)