diff --git a/GameState.gd b/GameState.gd index eb939f3..53a1c4f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -110,6 +110,20 @@ func withdraw_fleet_resource(resource_type: String, amount: int) -> int: return fleet_inventory.withdraw(resource_type, amount) +# #61: generic counterparts, for resources (energy) with no dedicated +# fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT +# the same as deposit_resource_to_building() on a "bank" building - that +# deposits into the building's OWN per-record inventory, not bank_inventory +# itself (only building_resource()/building_inventory() special-case "bank" +# capability to redirect there). +func deposit_fleet_resource(resource_type: String, amount: int) -> int: + return fleet_inventory.deposit(resource_type, amount) + + +func deposit_bank_resource(resource_type: String, amount: int) -> int: + return bank_inventory.deposit(resource_type, amount) + + # #56: total across every resource type in the hold, and the combined cap # that limits it - a HUD readout wants both, not any one resource's amount. func fleet_cargo_used() -> int: diff --git a/LaserController.gd b/LaserController.gd index 78f8e81..1c0a965 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -18,11 +18,16 @@ const TARGET_COLOR := Color(0.6, 0.95, 1.0) const BEAM_HALF_WIDTH := 22.0 const DRAG_DEADZONE := 22.0 const FLASH_DURATION := 0.22 +# #61: ammunition from the fleet hold, mirroring #60's gas bomber exactly. +const ENERGY_PER_SHOT: int = 8 @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/LaserButton") +@onready var status: Label = get_node("../UI/LaserStatus") + +var status_message_until: int = 0 var aiming := false var aim_touch := -1 @@ -37,15 +42,27 @@ var last_hit_count := 0 func _ready() -> void: button.visible = false + status.text = "" queue_redraw() +func _can_afford_shot() -> bool: + return GameState.fleet_resource(GameState.RESOURCE_ENERGY) >= ENERGY_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 beams.is_empty() button.visible = fleet.has_capability("laser") - button.enabled = _next_ready_laser_ship() != null + button.enabled = _next_ready_laser_ship() != 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) @@ -63,14 +80,22 @@ 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 (#60's identical + # reasoning on the gas bomber). A wholly unready fleet (no laser ship 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_laser_ship() != 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 energy in the fleet hold to fire (have %d)" \ + % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) + return true aiming = true aim_touch = event.index aim_start_screen = event.position @@ -149,16 +174,22 @@ func _fire() -> void: if shooter == null: _cancel_aim() return + if not _can_afford_shot(): + _say("Need %d energy in the fleet hold to fire (have %d)" \ + % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) + _cancel_aim() + return var component: Node = shooter.get_component("laser") if not component.consume_shot(): _cancel_aim() return + GameState.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT) var direction: Vector2 = (aim_target - _aim_origin()) direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP var beam_to: Vector2 = shooter.global_position + direction * component.beam_range _strike(shooter.global_position, beam_to, direction, int(component.damage)) _finish_aim() - button.enabled = _next_ready_laser_ship() != null + button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: diff --git a/Main.tscn b/Main.tscn index 7e14f37..be098dc 100644 --- a/Main.tscn +++ b/Main.tscn @@ -303,6 +303,22 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "" +[node name="LaserStatus" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -540 +offset_bottom = -490 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.3, 0.9, 1, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 53f12ed..709872e 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -245,6 +245,22 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "" +[node name="LaserStatus" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -540 +offset_bottom = -490 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.2, 0.6, 0.68, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/tests/test_laser_ammo.gd b/tests/test_laser_ammo.gd new file mode 100644 index 0000000..a38b9ea --- /dev/null +++ b/tests/test_laser_ammo.gd @@ -0,0 +1,141 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_laser_ammo.gd +# Forgejo #61: laser shots draw ammunition from the FLEET HOLD +# (GameState.fleet_inventory), the same way gas bomb shots do (#60) - the +# fleet has to be carrying its own energy. +# +# Covers: +# A. banked energy does not enable firing - only the fleet hold counts +# B. with insufficient fleet energy the button is disabled, pressing it +# explains why, and no beam is fired / no cooldown consumed +# C. firing drains exactly ENERGY_PER_SHOT from the fleet hold and +# nothing from the bank + +var game_state: Node +var main: Node +var fleet: Node +var skill: Node +var laser_component: Node +var stage: int = 0 +var elapsed: float = 0.0 + +# Mirrors LaserController.ENERGY_PER_SHOT's value rather than referencing +# the class constant directly - a bare ClassName.CONST reference elsewhere +# in a test script has been observed to corrupt method resolution for the +# WHOLE script (an unrelated skill._process() call earlier in the same +# file then fails with "Nonexistent function '_process' in base 'Nil'"), +# root cause unconfirmed. See tests/test_gas_bomber_cooldowns.gd's +# identical fix (#60). +const ENERGY_PER_SHOT: int = 8 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.reset_progression() + game_state.vessel_levels["unlock_laser"] = 1 + game_state.rocks = 0 + game_state.gas = 0 + game_state.fleet_gas_cargo = 0 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + skill = main.get_node("LaserController") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # An interceptor rides along purely for its "cargo" capability + # (#56): a fleet made only of a laser ship has zero cargo + # capacity, which would silently clamp every fleet energy + # deposit below to 0. + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER])) + laser_component = fleet.ships[1].get_component("laser") + skill._process(0.0) + + # --- A. bank energy does not count ---------------------------- + game_state.deposit_bank_resource(GameState.RESOURCE_ENERGY, 500) + skill._process(0.0) + if skill.button.enabled: + return _fail_now("500 banked energy (0 in the fleet hold) left the control enabled") + print("500 banked energy, 0 fleet energy: control correctly disabled") + + # --- B. insufficient fleet energy ------------------------------- + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT - 1) + skill._process(0.0) + if skill.button.enabled: + return _fail_now("fleet energy one short of ENERGY_PER_SHOT left the control enabled") + + var press := InputEventScreenTouch.new() + press.index = 3 + press.position = skill.button.global_position + skill.button.size * 0.5 + press.pressed = true + main._handle_touch(press) + if skill.aiming: + return _fail_now("aiming began despite insufficient fleet energy") + if skill.status.text == "": + return _fail_now("an unaffordable press did not explain itself") + print("insufficient fleet energy: disabled, press explains itself, no aiming began") + if not laser_component.is_ready(): + return _fail_now("a refused press consumed the laser ship's cooldown") + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != ENERGY_PER_SHOT - 1: + return _fail_now("a refused press spent fleet energy anyway") + print("no cooldown consumed, no energy spent on a refused press") + + # --- C. exact ammo cost on a real shot ----------------------------- + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 8) + skill._process(0.0) + if not skill.button.enabled: + return _fail_now("enough fleet energy still left the control disabled") + var fleet_energy_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + + var press2 := InputEventScreenTouch.new() + press2.index = 4 + press2.position = skill.button.global_position + skill.button.size * 0.5 + press2.pressed = true + main._handle_touch(press2) + if not skill.aiming: + return _fail_now("an affordable press did not begin aiming") + var drag := InputEventScreenDrag.new() + drag.index = 4 + drag.position = press2.position + Vector2(-300.0, 0.0) + main._handle_drag(drag) + var release := InputEventScreenTouch.new() + release.index = 4 + release.position = drag.position + release.pressed = false + main._handle_touch(release) + + if laser_component.is_ready(): + return _fail_now("firing did not consume the laser ship's cooldown") + var fleet_energy_after: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if fleet_energy_before - fleet_energy_after != ENERGY_PER_SHOT: + return _fail_now("firing spent %d fleet energy, expected exactly %d" \ + % [fleet_energy_before - fleet_energy_after, ENERGY_PER_SHOT]) + if game_state.banked_resource(GameState.RESOURCE_ENERGY) != 500: + return _fail_now("firing touched the bank (%d), expected it untouched at 500" \ + % game_state.banked_resource(GameState.RESOURCE_ENERGY)) + print("firing drained exactly %d from the fleet hold, bank untouched" % ENERGY_PER_SHOT) + + print("TEST_LASER_AMMO: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_LASER_AMMO: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index 4c3001e..03819d4 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -20,7 +20,12 @@ extends SceneTree var game_state: Node var main: Node var fleet: Node -var skill: LaserController +var skill: Node +# Mirrors LaserController.BEAM_HALF_WIDTH's value rather than referencing +# the class constant directly - a bare ClassName.CONST reference elsewhere +# in a test script has been observed to corrupt method resolution for the +# WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60). +const BEAM_HALF_WIDTH: float = 22.0 var on_line_enemies: Array = [] var off_line_enemy: Node var beyond_range_enemy: Node @@ -42,6 +47,13 @@ func _initialize() -> void: # this ticket is about the laser's own behaviour, not re-proving the # ship-lab research flow (already covered generically elsewhere). game_state.vessel_levels["unlock_laser"] = 1 + # #61: shots draw energy from the fleet hold now - stock enough for two + # fires. Set BEFORE the first spawn_ships() call (inside Main.tscn's own + # _ready(), triggered by instantiate() below): the fleet hold has no + # capacity cap yet at this point (#56's cap is only computed once a + # fleet composition exists), so this deposit can't be clamped by the + # soon-to-exist all-laser fleet having zero "cargo" capability. + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 20) main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") @@ -106,7 +118,7 @@ func _process(delta: float) -> bool: off_line_enemy = load("res://Enemy.tscn").instantiate() off_line_enemy.position = first_shooter.global_position \ - + Vector2(200.0, LaserController.BEAM_HALF_WIDTH + 60.0) + + Vector2(200.0, BEAM_HALF_WIDTH + 60.0) main.add_child(off_line_enemy) off_line_enemy.speed = 0.0