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.
This commit is contained in:
Loïc Wisniewski 2026-08-16 18:54:44 +02:00
commit 17d0cf7e66
8 changed files with 230 additions and 9 deletions

View file

@ -10,11 +10,17 @@ 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
@ -40,15 +46,27 @@ var explosion_time: float:
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
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)
@ -81,14 +99,23 @@ func _process(delta: float) -> void:
func can_begin_aim() -> bool:
return button.visible and button.enabled and not button._blocked_by_modal() \
and not aiming
# 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
@ -156,17 +183,23 @@ func _fire() -> void:
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
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
func _next_ready_bomber() -> Node2D: