From 1d9afcbc2ee8d8f02f4dabad8dba7f68b93c0e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 04:45:15 +0200 Subject: [PATCH] Add gas bomber skill-shot vessel --- BuildController.gd | 60 ++++++-- Fleet.gd | 25 +++ GameController.gd | 5 + GameState.gd | 114 +++++++++++--- GasBombController.gd | 197 ++++++++++++++++++++++++ GasBombController.gd.uid | 1 + GasBomberVesselDefinition.tres | 26 ++++ IconButton.gd | 26 ++++ Main.tscn | 188 +++++++++++++++++----- NeonDraw.gd | 10 ++ PlanetSurface.tscn | 22 ++- ShieldVesselDefinition.tres | 1 + VesselDefinition.gd | 3 + VesselDefinitions.gd | 3 +- VesselGasBomberComponent.gd | 3 + VesselGasBomberComponent.gd.uid | 1 + VesselGasBomberComponent.tscn | 6 + tests/_craft_menu_preview.gd | 9 +- tests/_gas_bomb_aim_preview.gd | 38 +++++ tests/_gas_bomb_aim_preview.gd.uid | 1 + tests/_gas_bomb_aim_preview.tscn | 6 + tests/_hangar_menu_preview.gd | 6 + tests/_lab_menu_preview.gd | 11 +- tests/test_gas_bomber.gd | 151 ++++++++++++++++++ tests/test_gas_bomber.gd.uid | 1 + tests/test_multi_resource_supply.gd | 42 +++++ tests/test_multi_resource_supply.gd.uid | 1 + tests/test_vessel_composition.gd | 10 +- 28 files changed, 882 insertions(+), 85 deletions(-) create mode 100644 GasBombController.gd create mode 100644 GasBombController.gd.uid create mode 100644 GasBomberVesselDefinition.tres create mode 100644 VesselGasBomberComponent.gd create mode 100644 VesselGasBomberComponent.gd.uid create mode 100644 VesselGasBomberComponent.tscn create mode 100644 tests/_gas_bomb_aim_preview.gd create mode 100644 tests/_gas_bomb_aim_preview.gd.uid create mode 100644 tests/_gas_bomb_aim_preview.tscn create mode 100644 tests/test_gas_bomber.gd create mode 100644 tests/test_gas_bomber.gd.uid create mode 100644 tests/test_multi_resource_supply.gd create mode 100644 tests/test_multi_resource_supply.gd.uid diff --git a/BuildController.gd b/BuildController.gd index ce9b43b..f316929 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -30,12 +30,12 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var lab_menu: Control = get_node("../UI/LabMenu") @onready var lab_vessel_buttons: Array = [ get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), - get_node("../UI/LabMenu/VesselC"), + get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"), ] @onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") @onready var lab_vessel_labels: Array = [ get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"), - get_node("../UI/LabMenu/VesselCLabel"), + get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @@ -62,18 +62,22 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var hangar_available_slots: Array = [ get_node("../UI/HangarMenu/AvailableInterceptor"), get_node("../UI/HangarMenu/AvailableShield"), + get_node("../UI/HangarMenu/AvailableGasBomber"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), get_node("../UI/HangarMenu/AvailableShieldLabel"), + get_node("../UI/HangarMenu/AvailableGasBomberLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), get_node("../UI/HangarMenu/FleetShield"), + get_node("../UI/HangarMenu/FleetGasBomber"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"), + get_node("../UI/HangarMenu/FleetGasBomberLabel"), ] var build_menu_entries: Array = [] @@ -97,11 +101,13 @@ var lab_building_id: int = 0 @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel") @onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") +@onready var craft_gas_bomber_button: IconButton = get_node("../UI/CraftMenu/GasBomberButton") +@onready var craft_gas_bomber_label: Label = get_node("../UI/CraftMenu/GasBomberLabel") @onready var craft_vessel_buttons: Array = [ - craft_interceptor_button, craft_shield_button, + craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, ] @onready var craft_vessel_labels: Array = [ - craft_interceptor_label, craft_shield_label, + craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, ] # --- the transfer panel (#24) --------------------------------------------- @@ -723,6 +729,25 @@ func _say(message: String) -> void: _refresh_craft_status() +func _format_costs(costs: Dictionary) -> String: + var parts: PackedStringArray = PackedStringArray() + for resource_type in GameState.resource_types(): + var amount: int = int(costs.get(resource_type, 0)) + if amount > 0: + parts.append("%d %s" % [amount, GameState.resource_name(resource_type).to_lower()]) + return " · ".join(parts) if not parts.is_empty() else "free" + + +func _format_missing_costs(costs: Dictionary) -> String: + var parts: PackedStringArray = PackedStringArray() + for resource_type in GameState.resource_types(): + var amount: int = int(costs.get(resource_type, 0)) + if amount > 0: + parts.append("%d more %s" % [amount, + GameState.resource_name(resource_type).to_lower()]) + return " · ".join(parts) + + func _refresh_craft_status() -> void: # Prices live with the buttons they describe. The top of the screen is # intentionally reserved for the shared resource bar and a short action @@ -732,7 +757,8 @@ func _refresh_craft_status() -> void: for i in range(mini(definitions.size(), craft_vessel_buttons.size())): var definition: Resource = definitions[i] var button: IconButton = craft_vessel_buttons[i] - craft_vessel_labels[i].text = "%s\n%d ore" % [definition.display_name, definition.build_cost] + craft_vessel_labels[i].text = "%s\n%s" % [definition.display_name, + _format_costs(GameState.product_costs(definition.definition_id))] button.icon = definition.icon button.glow_color = definition.glow_color button.enabled = GameState.vessel_is_unlocked(definition.definition_id) @@ -764,11 +790,10 @@ func _on_craft_vessel(vessel: String) -> void: func _report_order(product: String) -> void: - var short: int = GameState.outstanding_cost(craft_building_id) - if short > 0: - _say("%s queued - %d more ore must be delivered here" % [ - GameState.vessel_name(product), short - ]) + var missing: Dictionary = GameState.outstanding_costs(craft_building_id) + if not missing.is_empty(): + _say("%s queued - deliver %s here" % [GameState.vessel_name(product), + _format_missing_costs(missing)]) else: _say("%s ordered - building now" % GameState.vessel_name(product)) @@ -786,7 +811,8 @@ func _lab_options() -> Array: if not GameState.vessel_is_unlocked(lab_vessel): options.append({"unlock": true, "id": definition.unlock_id, "name": definition.unlock_name, "desc": definition.unlock_description, - "cost": definition.unlock_cost, "icon": IconButton.Icon.UPGRADE_UNLOCK_SHIELD}) + "costs": GameState.research_costs(definition.unlock_id), + "icon": definition.unlock_icon if definition.unlock_icon >= 0 else definition.icon}) return options for u in GameState.upgrades_for(lab_vessel): options.append({"unlock": false, "id": u.id, "name": u.name, "desc": u.desc}) @@ -819,7 +845,8 @@ func _on_lab_buy() -> void: if not GameState.queue_lab_research(lab_building_id, id): lab_status.text = "Research is already queued or complete" return - lab_status.text = "%s queued - deliver ore to this lab" % choice.name + lab_status.text = "%s queued - deliver %s to this lab" % [choice.name, + _format_costs(GameState.research_costs(id))] _refresh_lab() @@ -844,7 +871,8 @@ func _refresh_lab() -> void: if not visible: continue var option: Dictionary = options[i] - var price_text: String = "%d ore" % (int(option.cost) if bool(option.unlock) else GameState.upgrade_cost(option.id)) + var price_text: String = _format_costs(option.costs if bool(option.unlock) \ + else GameState.research_costs(option.id)) if not bool(option.unlock) and GameState.upgrade_maxed(option.id): price_text = "MAX" var level_note: String = "" if bool(option.unlock) else " %d/%d" % [ @@ -867,7 +895,7 @@ func _refresh_lab() -> void: var queued: Dictionary = GameState.lab_research_order(lab_building_id) lab_buy_button.enabled = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id)) if not queued.is_empty(): - lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for ore", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))] + lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))] lab_buy_button.queue_redraw() @@ -989,7 +1017,7 @@ func _refresh_queue() -> void: int(round(float(head.progress) * 100.0)), ] else: - queue_detail.text = "%s waiting for ore\nTap to cancel" % \ + queue_detail.text = "%s waiting for resources\nTap to cancel" % \ GameState.vessel_name(String(head.product)) @@ -1006,7 +1034,7 @@ func _on_queue_slot(index: int) -> void: var refunded: bool = bool(orders[index].started) if not GameState.cancel_order(craft_building_id, index): return - _say("%s cancelled%s" % [vessel, " - ore refunded here" if refunded else ""]) + _say("%s cancelled%s" % [vessel, " - resources refunded here" if refunded else ""]) _refresh_queue() diff --git a/Fleet.gd b/Fleet.gd index af83cdd..949c0ac 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -86,6 +86,31 @@ func vessel_types() -> PackedStringArray: return result +func has_vessel_type(vessel_type: String) -> bool: + for ship in ships: + if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type: + return true + return false + + +func first_vessel_of_type(vessel_type: String) -> Node2D: + for ship in ships: + if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type: + return ship + return null + + +func has_capability(capability: String) -> bool: + return first_ship_with_capability(capability) != null + + +func first_ship_with_capability(capability: String) -> Node2D: + for ship in ships: + if is_instance_valid(ship) and ship.has_capability(capability): + return ship + return null + + func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: # Adds one vessel in the next free formation slot (bought from a ship # crafter). Returns false when the wedge is already full. diff --git a/GameController.gd b/GameController.gd index c692546..5e9b21f 100644 --- a/GameController.gd +++ b/GameController.gd @@ -21,6 +21,7 @@ extends Node2D # Navigation section. Optional so a scene without it still works. @onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton") @onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton") +@onready var gas_bomb_controller: GasBombController = get_node_or_null("GasBombController") var touches: Dictionary = {} var pinch_active: bool = false @@ -252,6 +253,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: # The joystick gets first refusal on every touch, and only claims ones # that begin inside its own rect - so pinch-to-zoom still works anywhere # else on screen. + if gas_bomb_controller != null and gas_bomb_controller.handle_touch(event): + return if _handle_pivot_touch(event): return if joystick != null and joystick.visible and joystick.handle_touch(event): @@ -272,6 +275,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: func _handle_drag(event: InputEventScreenDrag) -> void: + if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event): + return if joystick != null and joystick.visible and joystick.handle_drag(event): return if not touches.has(event.index): diff --git a/GameState.gd b/GameState.gd index cfc540a..5366a49 100644 --- a/GameState.gd +++ b/GameState.gd @@ -410,6 +410,7 @@ func building_has_capability(building: Dictionary, capability: String) -> bool: # but transporters themselves belong to hubs and are never production orders. const VESSEL_INTERCEPTOR: String = "interceptor" const VESSEL_SHIELD: String = "shield" +const VESSEL_GAS_BOMBER: String = "gas_bomber" const VESSEL_TRANSPORTER: String = "transporter" # Compatibility constants for callers and old tests. Runtime metadata is @@ -501,8 +502,11 @@ func unlock_vessel(vessel: String) -> bool: var definition: Resource = vessel_definition(vessel) if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab(): return false - if not spend(definition.unlock_cost): + var costs: Dictionary = research_costs(definition.unlock_id) + if not can_afford_resources(costs): return false + for resource_type in costs: + bank_inventory.withdraw(String(resource_type), int(costs[resource_type])) vessel_levels[definition.unlock_id] = 1 vessel_upgraded.emit(definition.unlock_id) return true @@ -604,8 +608,16 @@ const PRODUCT_SHIP: String = "interceptor" func product_cost(product: String) -> int: + return int(product_costs(product).get(RESOURCE_ORE, 0)) + + +func product_costs(product: String) -> Dictionary: var definition: Resource = vessel_definition(product) - return definition.build_cost if definition != null else 0 + if definition == null: + return {} + if not definition.build_resource_costs.is_empty(): + return definition.build_resource_costs.duplicate() + return {RESOURCE_ORE: definition.build_cost} if definition.build_cost > 0 else {} func can_produce(type: String) -> bool: @@ -661,34 +673,53 @@ func cancel_order(id: int, index: int) -> bool: return false var order: Dictionary = orders[index] if bool(order.started): - _inventory_for_record(b).deposit(RESOURCE_ORE, product_cost(String(order.product))) + var inventory: RefCounted = _inventory_for_record(b) + var costs: Dictionary = product_costs(String(order.product)) + for resource_type in costs: + inventory.deposit(String(resource_type), int(costs[resource_type])) orders.remove_at(index) return true func outstanding_cost(id: int) -> int: + return outstanding_resource_cost(id, RESOURCE_ORE) + + +func outstanding_costs(id: int) -> Dictionary: + var result: Dictionary = {} + for resource_type in resource_types(): + var need: int = outstanding_resource_cost(id, resource_type) + if need > 0: + result[resource_type] = need + return result + + +func outstanding_resource_cost(id: int, resource_type: String) -> int: # What this building still needs delivered to clear everything queued. var b: Dictionary = find_building(id) if b.is_empty(): return 0 var need: int = 0 for o in b.get("orders", []): - # A started build has already paid, so it is not still owed. if not bool(o.started): - need += product_cost(String(o.product)) + need += int(product_costs(String(o.product)).get(resource_type, 0)) for order in b.get("research_orders", []): if not bool(order.started): - need += research_cost(String(order.id)) - return maxi(need - _inventory_for_record(b).amount(RESOURCE_ORE), 0) + need += int(research_costs(String(order.id)).get(resource_type, 0)) + return maxi(need - _inventory_for_record(b).amount(resource_type), 0) func deposit_to_building(id: int, amount: int) -> int: + return deposit_resource_to_building(id, RESOURCE_ORE, amount) + + +func deposit_resource_to_building(id: int, resource_type: String, amount: int) -> int: if amount <= 0: return 0 var b: Dictionary = find_building(id) if b.is_empty(): return 0 - return _inventory_for_record(b).deposit(RESOURCE_ORE, amount) + return _inventory_for_record(b).deposit(resource_type, amount) func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: @@ -717,14 +748,20 @@ func _advance_production(delta: float) -> void: # this did before #23) leaves nothing to see or cancel. var order: Dictionary = orders[0] var product: String = String(order.product) - var cost: int = product_cost(product) + var costs: Dictionary = product_costs(product) var inventory: RefCounted = _inventory_for_record(b) var definition: Resource = vessel_definition(product) if not bool(order.started): - if inventory.amount(RESOURCE_ORE) < cost: + var affordable := true + for resource_type in costs: + if inventory.amount(String(resource_type)) < int(costs[resource_type]): + affordable = false + break + if not affordable: continue - inventory.withdraw(RESOURCE_ORE, cost) + for resource_type in costs: + inventory.withdraw(String(resource_type), int(costs[resource_type])) order.started = true order.elapsed = float(order.elapsed) + delta @@ -764,8 +801,17 @@ func lab_research_order(lab_id: int) -> Dictionary: func research_cost(id: String) -> int: + return int(research_costs(id).get(RESOURCE_ORE, 0)) + + +func research_costs(id: String) -> Dictionary: var unlock_definition: Resource = vessel_for_unlock(id) - return unlock_definition.unlock_cost if unlock_definition != null else upgrade_cost(id) + if unlock_definition != null: + if not unlock_definition.unlock_resource_costs.is_empty(): + return unlock_definition.unlock_resource_costs.duplicate() + return {RESOURCE_ORE: unlock_definition.unlock_cost} if unlock_definition.unlock_cost > 0 else {} + var cost: int = upgrade_cost(id) + return {RESOURCE_ORE: cost} if cost > 0 else {} func _advance_lab_research(delta: float) -> void: @@ -778,12 +824,18 @@ func _advance_lab_research(delta: float) -> void: var order: Dictionary = orders[0] var id: String = String(order.id) var unlock_definition: Resource = vessel_for_unlock(id) - var cost: int = research_cost(id) + var costs: Dictionary = research_costs(id) var inventory: RefCounted = _inventory_for_record(lab) if not bool(order.started): - if inventory.amount(RESOURCE_ORE) < cost: + var affordable := true + for resource_type in costs: + if inventory.amount(String(resource_type)) < int(costs[resource_type]): + affordable = false + break + if not affordable: continue - inventory.withdraw(RESOURCE_ORE, cost) + for resource_type in costs: + inventory.withdraw(String(resource_type), int(costs[resource_type])) order.started = true order.elapsed = float(order.elapsed) + delta if float(order.elapsed) < lab_research_time(): @@ -1129,7 +1181,13 @@ func _dispatch_transporters() -> void: _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, TRANSPORTER_TO_EXTRACTOR) for b in buildings: - if outstanding_cost(int(b.id)) <= 0: + var needed_resource := "" + for resource_type in resource_types(): + if outstanding_resource_cost(int(b.id), resource_type) > 0 \ + and bank_inventory.amount(resource_type) > 0: + needed_resource = resource_type + break + if needed_resource == "": continue # One at a time per building: without this every idle transporter # would be sent after the same shortfall and they would all arrive @@ -1149,6 +1207,7 @@ func _dispatch_transporters() -> void: if chosen.is_empty(): return # every transporter is busy chosen.target_id = int(b.id) + chosen.resource_type = needed_resource _begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP) @@ -1175,22 +1234,24 @@ func _arrive(t: Dictionary) -> void: match t.state: TRANSPORTER_TO_MOTHERSHIP: var target: Dictionary = find_building(int(t.target_id)) - var want: int = outstanding_cost(int(t.target_id)) - var take: int = mini(mini(want, bank_inventory.amount(RESOURCE_ORE)), transporter_load_limit()) + var resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) + var want: int = outstanding_resource_cost(int(t.target_id), resource_type) + var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit()) if take <= 0 or target.is_empty(): # Nothing to carry, or the building is gone. Go home empty. t.target_id = 0 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) return - bank_inventory.withdraw(RESOURCE_ORE, take) + bank_inventory.withdraw(resource_type, take) t.carrying = take _begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING) TRANSPORTER_TO_BUILDING: - # The ore moves at exactly the moment the vessel arrives. - if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0: - # Building demolished in transit - put the ore back rather + # The cargo moves at exactly the moment the vessel arrives. + var resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) + if deposit_resource_to_building(int(t.target_id), resource_type, int(t.carrying)) <= 0: + # Building demolished in transit - put the cargo back rather # than quietly destroying it. - bank_inventory.deposit(RESOURCE_ORE, int(t.carrying)) + bank_inventory.deposit(resource_type, int(t.carrying)) t.carrying = 0 t.target_id = 0 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) @@ -1226,6 +1287,13 @@ func can_afford(cost: int) -> bool: return bank_inventory.amount(RESOURCE_ORE) >= cost +func can_afford_resources(costs: Dictionary) -> bool: + for resource_type in costs: + if bank_inventory.amount(String(resource_type)) < int(costs[resource_type]): + return false + return true + + func spend(cost: int) -> bool: if not can_afford(cost): return false diff --git a/GasBombController.gd b/GasBombController.gd new file mode 100644 index 0000000..2accdd9 --- /dev/null +++ b/GasBombController.gd @@ -0,0 +1,197 @@ +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 + +@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") + +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 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 last_hit_count := 0 + + +func _ready() -> void: + button.visible = false + queue_redraw() + + +func _process(delta: float) -> void: + var was_exploding := explosion_time > 0.0 + button.visible = fleet.has_capability("gas_bomber") + cooldown_left = maxf(cooldown_left - delta, 0.0) + button.enabled = cooldown_left <= 0.0 + if not button.visible and aiming: + _cancel_aim() + + 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 + + if projectile_active: + var distance := projectile_position.distance_to(projectile_target) + if distance <= PROJECTILE_SPEED * delta: + projectile_position = projectile_target + projectile_active = false + _explode(projectile_target) + else: + projectile_position = projectile_position.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: + 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 + + +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 + 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 = fleet.first_ship_with_capability("gas_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 + _finish_aim() + + +func _explode(at: Vector2) -> void: + explosion_position = at + explosion_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) + 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 + 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 := 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) diff --git a/GasBombController.gd.uid b/GasBombController.gd.uid new file mode 100644 index 0000000..e67f3e1 --- /dev/null +++ b/GasBombController.gd.uid @@ -0,0 +1 @@ +uid://dj1n53msflyde diff --git a/GasBomberVesselDefinition.tres b/GasBomberVesselDefinition.tres new file mode 100644 index 0000000..d464a4e --- /dev/null +++ b/GasBomberVesselDefinition.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://VesselGasBomberComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +definition_id = "gas_bomber" +display_name = "Gas Bomber" +description = "Area-control vessel that launches volatile gas bombs at a manually aimed target." +build_cost = 10 +build_resource_costs = {"gas": 8, "ore": 10} +build_time = 18.0 +icon = 31 +glow_color = Color(0.96, 0.32, 0.78, 1) +hull_points = PackedVector2Array(0, -24, -24, 14, -10, 10, 0, 22, 10, 10, 24, 14) +fleet_eligible = true +capabilities = PackedStringArray("gas_bomber") +component_scenes = Array[PackedScene]([ExtResource("2")]) +base_stats = {"max_speed": 420.0, "acceleration": 400.0, "delivery_speed": 180.0} +unlock_id = "unlock_gas_bomber" +unlock_name = "Unlock Gas Bomber" +unlock_description = "Research volatile-gas containment and unlock the Gas Bomber for ship crafters." +unlock_cost = 0 +unlock_resource_costs = {"gas": 30} +unlock_icon = 31 diff --git a/IconButton.gd b/IconButton.gd index ef5b378..ca019ca 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -30,6 +30,7 @@ enum Icon { LEFT_ARROW, RIGHT_ARROW, MINUS, PLUS, SHIELD_VESSEL, + GAS_BOMBER, GAS_BOMB, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -216,6 +217,10 @@ func _draw() -> void: _draw_plus(center, pulse, s) Icon.SHIELD_VESSEL: _draw_shield_vessel(center, pulse, s) + Icon.GAS_BOMBER: + _draw_gas_bomber(center, pulse, s) + Icon.GAS_BOMB: + _draw_gas_bomb(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -240,6 +245,27 @@ func _draw_shield_vessel(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, points, _active_color(), pulse, core_color) +func _draw_gas_bomber(center: Vector2, pulse: float, s: float) -> void: + var hull := PackedVector2Array([ + center + Vector2(0, -12) * s, + center + Vector2(-12, 7) * s, + center + Vector2(-5, 5) * s, + center + Vector2(0, 11) * s, + center + Vector2(5, 5) * s, + center + Vector2(12, 7) * s, + ]) + NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color) + NeonDraw.draw_glowing_circle(self, center + Vector2(0, 2) * s, 4.0 * s, + _active_color(), pulse, core_color) + + +func _draw_gas_bomb(center: Vector2, pulse: float, s: float) -> void: + NeonDraw.draw_glowing_circle(self, center, 7.0 * s, _active_color(), pulse, core_color) + for direction in [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT]: + NeonDraw.draw_glowing_circle(self, center + direction * 9.0 * s, 3.5 * s, + _active_color(), pulse * 0.85, core_color) + + func _draw_arrow(center: Vector2, pulse: float, direction: float, s: float) -> void: var r: float = 9.0 * s var tip: Vector2 = center + Vector2(0, r * direction) diff --git a/Main.tscn b/Main.tscn index 6700644..df66353 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=29 format=3] +[gd_scene load_steps=30 format=3] [ext_resource type="Script" path="res://Starfield.gd" id="1"] [ext_resource type="Script" path="res://Fleet.gd" id="2"] @@ -27,6 +27,7 @@ [ext_resource type="Script" path="res://VesselDeliveryField.gd" id="26"] [ext_resource type="Script" path="res://ResourceTransferList.gd" id="27"] [ext_resource type="Script" path="res://GasCargoCounter.gd" id="28"] +[ext_resource type="Script" path="res://GasBombController.gd" id="29"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -55,6 +56,9 @@ script = ExtResource("1") [node name="Fleet" type="Node2D" parent="."] script = ExtResource("2") +[node name="GasBombController" type="Node2D" parent="."] +script = ExtResource("29") + [node name="Camera2D" type="Camera2D" parent="."] position = Vector2(0, 0) zoom = Vector2(1, 1) @@ -256,6 +260,21 @@ offset_bottom = -24 script = ExtResource("10") icon = 7 +[node name="GasBombButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = -344 +offset_bottom = -196 +script = ExtResource("10") +icon = 32 +glow_color = Color(0.96, 0.22, 0.72, 1) + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 @@ -788,8 +807,8 @@ horizontal_alignment = 1 [node name="AvailableInterceptor" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -180 -offset_right = -60 +offset_left = -290 +offset_right = -178 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -799,8 +818,8 @@ icon = 2 mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -250 -offset_right = 10 +offset_left = -330 +offset_right = -138 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -809,8 +828,8 @@ horizontal_alignment = 1 [node name="AvailableShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 60 -offset_right = 180 +offset_left = -56 +offset_right = 56 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -821,8 +840,30 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -10 -offset_right = 250 +offset_left = -96 +offset_right = 96 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="AvailableGasBomber" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 178 +offset_right = 290 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 31 +glow_color = Color(0.96, 0.32, 0.78, 1) + +[node name="AvailableGasBomberLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 138 +offset_right = 330 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -840,8 +881,8 @@ horizontal_alignment = 1 [node name="FleetInterceptor" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -180 -offset_right = -60 +offset_left = -290 +offset_right = -178 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -851,8 +892,8 @@ icon = 2 mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -250 -offset_right = 10 +offset_left = -330 +offset_right = -138 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -861,8 +902,8 @@ horizontal_alignment = 1 [node name="FleetShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 60 -offset_right = 180 +offset_left = -56 +offset_right = 56 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -873,8 +914,30 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -10 -offset_right = 250 +offset_left = -96 +offset_right = 96 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetGasBomber" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 178 +offset_right = 290 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 31 +glow_color = Color(0.96, 0.32, 0.78, 1) + +[node name="FleetGasBomberLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 138 +offset_right = 330 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -924,8 +987,8 @@ script = ExtResource("24") [node name="VesselA" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -290 -offset_right = -170 +offset_left = -300 +offset_right = -190 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -935,8 +998,8 @@ glow_color = Color(0.25, 0.85, 1, 1) [node name="VesselB" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -60 -offset_right = 60 +offset_left = -145 +offset_right = -35 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -946,8 +1009,19 @@ glow_color = Color(0.45, 0.8, 1, 1) [node name="VesselC" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 170 -offset_right = 290 +offset_left = 35 +offset_right = 145 +offset_top = 274 +offset_bottom = 394 +script = ExtResource("10") +icon = 31 +glow_color = Color(0.96, 0.32, 0.78, 1) + +[node name="VesselD" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 190 +offset_right = 300 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -971,7 +1045,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -130 +offset_right = -160 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -982,8 +1056,8 @@ text = "Interceptor" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -100 -offset_right = 100 +offset_left = -175 +offset_right = -5 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -994,7 +1068,19 @@ text = "Shield" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 130 +offset_left = 5 +offset_right = 175 +offset_top = 400 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 +text = "Gas Bomber" + +[node name="VesselDLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 160 offset_right = 330 offset_top = 400 offset_bottom = 460 @@ -1246,8 +1332,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -220 -offset_right = -72 +offset_left = -310 +offset_right = -182 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1259,11 +1345,11 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -290 -offset_right = -2 +offset_left = -330 +offset_right = -162 offset_top = 56 offset_bottom = 136 -theme_override_font_sizes/font_size = 32 +theme_override_font_sizes/font_size = 24 text = "Interceptor" horizontal_alignment = 1 @@ -1272,8 +1358,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 72 -offset_right = 220 +offset_left = -148 +offset_right = -20 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1286,15 +1372,43 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 2 -offset_right = 290 +offset_left = -168 +offset_right = 0 offset_top = 56 offset_bottom = 136 -theme_override_font_sizes/font_size = 32 +theme_override_font_sizes/font_size = 24 autowrap_mode = 2 text = "Shield Vessel" horizontal_alignment = 1 +[node name="GasBomberButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 14 +offset_right = 142 +offset_top = -100 +offset_bottom = 28 +script = ExtResource("10") +icon = 31 +glow_color = Color(0.96, 0.32, 0.78, 1) + +[node name="GasBomberLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -6 +offset_right = 162 +offset_top = 56 +offset_bottom = 150 +theme_override_font_sizes/font_size = 24 +autowrap_mode = 2 +text = "Gas Bomber" +horizontal_alignment = 1 + [node name="QueueLabel" type="Label" parent="UI/CraftMenu"] mouse_filter = 2 anchor_left = 1.0 diff --git a/NeonDraw.gd b/NeonDraw.gd index 8a0e22a..403f951 100644 --- a/NeonDraw.gd +++ b/NeonDraw.gd @@ -26,6 +26,16 @@ static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow _draw_layers(item, PackedVector2Array([from, to]), glow_color, pulse, width_scale, core_color) +static func draw_glowing_circle(item: CanvasItem, center: Vector2, radius: float, + glow_color: Color, pulse: float = 1.0, + core_color: Color = DEFAULT_CORE) -> void: + var points := PackedVector2Array() + for i in range(24): + var angle := TAU * float(i) / 24.0 + points.append(center + Vector2(cos(angle), sin(angle)) * radius) + draw_glowing_polygon(item, points, glow_color, pulse, core_color) + + # An open run of points - unlike draw_glowing_polygon, the ends are NOT # joined. For arcs, cradle ribs and other shapes that are deliberately open. static func draw_glowing_path(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 81cea2d..c9d7a26 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=15 format=3] +[gd_scene load_steps=16 format=3] [ext_resource type="Script" path="res://GameController.gd" id="1"] [ext_resource type="Script" path="res://LunarBackground.gd" id="2"] @@ -11,6 +11,7 @@ [ext_resource type="Script" path="res://GasCounter.gd" id="11"] [ext_resource type="Script" path="res://CargoCounter.gd" id="12"] [ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"] +[ext_resource type="Script" path="res://GasBombController.gd" id="14"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("3") @@ -43,6 +44,9 @@ script = ExtResource("4") ship_glow_color = Color(0.06, 0.32, 0.5, 1) ship_core_color = Color(0.02, 0.09, 0.15, 1) +[node name="GasBombController" type="Node2D" parent="."] +script = ExtResource("14") + [node name="Camera2D" type="Camera2D" parent="."] position = Vector2(0, 0) zoom = Vector2(1, 1) @@ -180,6 +184,22 @@ icon = 7 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +[node name="GasBombButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = -344 +offset_bottom = -196 +script = ExtResource("6") +icon = 32 +glow_color = Color(0.82, 0.08, 0.48, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres index 0ca7bcc..aa4c440 100644 --- a/ShieldVesselDefinition.tres +++ b/ShieldVesselDefinition.tres @@ -24,3 +24,4 @@ unlock_id = "unlock_shield" unlock_name = "Unlock Shield Vessel" unlock_description = "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up." unlock_cost = 45 +unlock_icon = 23 diff --git a/VesselDefinition.gd b/VesselDefinition.gd index f5e28c8..1aecbfb 100644 --- a/VesselDefinition.gd +++ b/VesselDefinition.gd @@ -5,6 +5,7 @@ extends Resource @export var display_name: String @export_multiline var description: String @export var build_cost: int +@export var build_resource_costs: Dictionary = {} @export var build_time: float = 10.0 @export var icon: int = 2 @export var glow_color: Color = Color(0.25, 0.85, 1.0) @@ -21,6 +22,8 @@ extends Resource @export var unlock_name: String @export_multiline var unlock_description: String @export var unlock_cost: int +@export var unlock_resource_costs: Dictionary = {} +@export var unlock_icon: int = -1 func has_capability(capability: String) -> bool: diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd index 38d4734..eeb3bca 100644 --- a/VesselDefinitions.gd +++ b/VesselDefinitions.gd @@ -1,10 +1,11 @@ class_name VesselDefinitions extends RefCounted -const ORDER: PackedStringArray = ["interceptor", "shield", "transporter"] +const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter"] const DEFINITIONS: Dictionary = { "interceptor": preload("res://InterceptorVesselDefinition.tres"), "shield": preload("res://ShieldVesselDefinition.tres"), + "gas_bomber": preload("res://GasBomberVesselDefinition.tres"), "transporter": preload("res://TransporterVesselDefinition.tres"), } diff --git a/VesselGasBomberComponent.gd b/VesselGasBomberComponent.gd new file mode 100644 index 0000000..2ab77cf --- /dev/null +++ b/VesselGasBomberComponent.gd @@ -0,0 +1,3 @@ +extends Node + +var capability_id := "gas_bomber" diff --git a/VesselGasBomberComponent.gd.uid b/VesselGasBomberComponent.gd.uid new file mode 100644 index 0000000..0ea3d33 --- /dev/null +++ b/VesselGasBomberComponent.gd.uid @@ -0,0 +1 @@ +uid://cim0c4n73qd2j diff --git a/VesselGasBomberComponent.tscn b/VesselGasBomberComponent.tscn new file mode 100644 index 0000000..090a40d --- /dev/null +++ b/VesselGasBomberComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://VesselGasBomberComponent.gd" id="1"] + +[node name="GasBomberComponent" type="Node"] +script = ExtResource("1") diff --git a/tests/_craft_menu_preview.gd b/tests/_craft_menu_preview.gd index 317532c..c78556f 100644 --- a/tests/_craft_menu_preview.gd +++ b/tests/_craft_menu_preview.gd @@ -17,12 +17,19 @@ func _ready() -> void: GameState.fleet_cargo = 24 GameState.fleet_gas_cargo = 12 GameState.unlocked = ["shipyards", "logistics", "freight"] + GameState.vessel_levels["unlock_gas_bomber"] = 1 var main: Node = load("res://Main.tscn").instantiate() add_child(main) + main.get_node("BackgroundLayer").visible = false + main.get_node("Fleet").visible = false + main.get_node("AsteroidField").visible = false + main.get_node("PlanetField").visible = false + main.get_node("BuildingField").visible = false + main.get_node("EnemySpawner").set_process(false) main.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0)) var crafter_id: int = int(GameState.buildings[-1].id) GameState.deposit_to_building(crafter_id, 42) - GameState.find_building(crafter_id).gas_stock = 18 + GameState.deposit_resource_to_building(crafter_id, GameState.RESOURCE_GAS, 18) var bc: Node = main.get_node("BuildController") bc.craft_building_id = crafter_id # The shared header (#24) is the thing worth eyeballing here, so give it diff --git a/tests/_gas_bomb_aim_preview.gd b/tests/_gas_bomb_aim_preview.gd new file mode 100644 index 0000000..a0ac6a0 --- /dev/null +++ b/tests/_gas_bomb_aim_preview.gd @@ -0,0 +1,38 @@ +extends Node2D + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.vessel_levels["unlock_gas_bomber"] = 1 + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("BackgroundLayer").visible = false + main.get_node("AsteroidField").visible = false + main.get_node("PlanetField").visible = false + main.get_node("BuildingField").visible = false + 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([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, + GameState.VESSEL_GAS_BOMBER])) + var skill: GasBombController = main.get_node("GasBombController") + skill._process(0.0) + main.get_node("Camera2D").zoom = Vector2(1.8, 1.8) + var button: Control = main.get_node("UI/GasBombButton") + var press := InputEventScreenTouch.new() + press.index = 9 + press.position = button.global_position + button.size * 0.5 + press.pressed = true + skill.handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = 9 + drag.position = press.position + Vector2(-245, -235) + skill.handle_drag(drag) + for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]: + var enemy: Node = load("res://Enemy.tscn").instantiate() + enemy.position = skill.aim_target + offset + enemy.speed = 0.0 + main.add_child(enemy) diff --git a/tests/_gas_bomb_aim_preview.gd.uid b/tests/_gas_bomb_aim_preview.gd.uid new file mode 100644 index 0000000..54e887a --- /dev/null +++ b/tests/_gas_bomb_aim_preview.gd.uid @@ -0,0 +1 @@ +uid://dcokscjlrguje diff --git a/tests/_gas_bomb_aim_preview.tscn b/tests/_gas_bomb_aim_preview.tscn new file mode 100644 index 0000000..6b8725e --- /dev/null +++ b/tests/_gas_bomb_aim_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_gas_bomb_aim_preview.gd" id="1"] + +[node name="GasBombAimPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_hangar_menu_preview.gd b/tests/_hangar_menu_preview.gd index 5801cdc..38a44ec 100644 --- a/tests/_hangar_menu_preview.gd +++ b/tests/_hangar_menu_preview.gd @@ -11,13 +11,19 @@ func _ready() -> void: GameState.stored_vessels = [ GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_SHIELD, + GameState.VESSEL_GAS_BOMBER, ] var main: Node = load("res://Main.tscn").instantiate() add_child(main) + main.get_node("BackgroundLayer").visible = false + main.get_node("AsteroidField").visible = false + main.get_node("PlanetField").visible = false + main.get_node("BuildingField").visible = false main.get_node("AsteroidField").set_process(false) main.get_node("EnemySpawner").set_process(false) main.get_node("Fleet").spawn_ships(3, Vector2.ZERO, PackedStringArray([ GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, + GameState.VESSEL_GAS_BOMBER, ])) main.get_node("BuildController")._on_hangar_pressed() diff --git a/tests/_lab_menu_preview.gd b/tests/_lab_menu_preview.gd index 2552f58..d78c1b6 100644 --- a/tests/_lab_menu_preview.gd +++ b/tests/_lab_menu_preview.gd @@ -16,19 +16,22 @@ func _ready() -> void: GameState.unlocked = ["shipyards", "vessel_science"] var main: Node = load("res://Main.tscn").instantiate() add_child(main) + main.get_node("BackgroundLayer").visible = false + main.get_node("Fleet").visible = false + main.get_node("AsteroidField").visible = false + main.get_node("PlanetField").visible = false + main.get_node("BuildingField").visible = false + main.get_node("EnemySpawner").set_process(false) main.get_node("BuildingField").place(GameState.BUILDING_SHIP_LAB, Vector2(400.0, 0.0)) var lab_id: int = int(GameState.buildings[-1].id) GameState.deposit_to_building(lab_id, 18) GameState.fleet_cargo = 12 GameState.fleet_gas_cargo = 8 - # A level already bought, so the list shows both a researched and an - # unresearched line rather than a uniform page of zeroes. - GameState.vessel_levels["int_damage"] = 1 var bc: Node = main.get_node("BuildController") # The shared header (#24) hides its HERE column when it has no building, # so bind it the way opening the panel from the building does. for bar in bc.resource_bars: bar.building_id = lab_id main.get_node("UI/LabMenu").visible = true - bc.lab_vessel = GameState.VESSEL_INTERCEPTOR + bc.lab_vessel = GameState.VESSEL_GAS_BOMBER bc._refresh_lab() diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd new file mode 100644 index 0000000..8bd1210 --- /dev/null +++ b/tests/test_gas_bomber.gd @@ -0,0 +1,151 @@ +extends SceneTree + +var game_state: Node +var main: Node +var fleet: Node +var skill: GasBombController +var inside_enemies: Array = [] +var outside_enemy: Node +var stage := 0 +var elapsed := 0.0 +var original_zoom := 2.5 + + +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.vessel_deliveries.clear() + game_state.reset_progression() + game_state.rocks = 0 + game_state.gas = 0 + game_state.fleet_cargo = 0 + game_state.fleet_gas_cargo = 0 + game_state.build_time_scale = 0.01 + + # Research is gas-only: any amount of ore and 29 gas must still leave it + # waiting, then the thirtieth unit starts and completes the unlock. + var lab_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO) + if not game_state.queue_lab_research(lab_id, "unlock_gas_bomber"): + _fail_now("could not queue Gas Bomber research") + return + game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_ORE, 200) + game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_GAS, 29) + game_state._advance_lab_research(10.0) + if game_state.outstanding_resource_cost(lab_id, GameState.RESOURCE_GAS) != 1 \ + or game_state.outstanding_resource_cost(lab_id, GameState.RESOURCE_ORE) != 0: + _fail_now("research shortfall was not tracked as gas") + return + if game_state.vessel_is_unlocked(GameState.VESSEL_GAS_BOMBER): + _fail_now("Gas Bomber unlocked without its full gas cost") + return + game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_GAS, 1) + game_state._advance_lab_research(10.0) + if not game_state.vessel_is_unlocked(GameState.VESSEL_GAS_BOMBER): + _fail_now("30 gas did not unlock the Gas Bomber") + return + + # Crafting requires both resources and preserves the vessel type. + var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2.ZERO) + if not game_state.queue_order(crafter_id, GameState.VESSEL_GAS_BOMBER): + _fail_now("unlocked Gas Bomber could not be queued") + return + game_state.deposit_resource_to_building(crafter_id, GameState.RESOURCE_ORE, 10) + game_state.deposit_resource_to_building(crafter_id, GameState.RESOURCE_GAS, 7) + game_state._advance_production(10.0) + if game_state.outstanding_resource_cost(crafter_id, GameState.RESOURCE_GAS) != 1 \ + or game_state.outstanding_resource_cost(crafter_id, GameState.RESOURCE_ORE) != 0: + _fail_now("crafting shortfall was not tracked as gas") + return + if not game_state.order_list(crafter_id).is_empty() \ + and bool(game_state.order_list(crafter_id)[0].started): + _fail_now("Gas Bomber started with insufficient gas") + return + game_state.deposit_resource_to_building(crafter_id, GameState.RESOURCE_GAS, 1) + game_state._advance_production(10.0) + if game_state.stored_vessels != [GameState.VESSEL_GAS_BOMBER]: + _fail_now("crafting did not produce a typed Gas Bomber") + return + + 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 + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + if main.get_node("UI/GasBombButton").visible: + return _fail_now("bomb control appeared without a Gas Bomber") + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_GAS_BOMBER])) + main.get_node("Camera2D").zoom = Vector2(original_zoom, original_zoom) + skill._process(0.0) + if not main.get_node("UI/GasBombButton").visible: + return _fail_now("fleet Gas Bomber did not reveal the bomb control") + + var press := InputEventScreenTouch.new() + press.index = 7 + var bomb_button: Control = main.get_node("UI/GasBombButton") + press.position = bomb_button.global_position + bomb_button.size * 0.5 + press.pressed = true + main._handle_touch(press) + if not skill.aiming: + return _fail_now("bomb control did not begin aiming") + var drag := InputEventScreenDrag.new() + drag.index = 7 + drag.position = Vector2(350, 700) + main._handle_drag(drag) + if main.get_node("Camera2D").zoom.x >= original_zoom: + return _fail_now("long aim did not zoom out") + + for offset in [Vector2(-60, 0), Vector2(70, 35)]: + var enemy: Node = load("res://Enemy.tscn").instantiate() + enemy.position = skill.aim_target + offset + enemy.speed = 0.0 + main.add_child(enemy) + inside_enemies.append(enemy) + outside_enemy = load("res://Enemy.tscn").instantiate() + outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0) + outside_enemy.speed = 0.0 + main.add_child(outside_enemy) + + var release := InputEventScreenTouch.new() + release.index = 7 + release.position = drag.position + release.pressed = false + main._handle_touch(release) + if not skill.projectile_active: + return _fail_now("release did not throw a gas bomb") + elapsed = 0.0 + stage = 1 + 1: + if elapsed < 2.5: + return false + if skill.last_hit_count != 2: + return _fail_now("AOE hit %d enemies instead of 2" % skill.last_hit_count) + for enemy in inside_enemies: + if is_instance_valid(enemy): + return _fail_now("an enemy inside the AOE survived") + if not is_instance_valid(outside_enemy) or outside_enemy.hp != outside_enemy.max_hp: + return _fail_now("the AOE damaged an enemy outside its radius") + if absf(main.get_node("Camera2D").zoom.x - original_zoom) > 0.03: + return _fail_now("camera did not restore its pre-aim zoom") + print("TEST_GAS_BOMBER: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_GAS_BOMBER: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_gas_bomber.gd.uid b/tests/test_gas_bomber.gd.uid new file mode 100644 index 0000000..e3fcd6c --- /dev/null +++ b/tests/test_gas_bomber.gd.uid @@ -0,0 +1 @@ +uid://c3vtwrwjpmcbm diff --git a/tests/test_multi_resource_supply.gd b/tests/test_multi_resource_supply.gd new file mode 100644 index 0000000..45a016a --- /dev/null +++ b/tests/test_multi_resource_supply.gd @@ -0,0 +1,42 @@ +extends SceneTree + + +func _initialize() -> void: + var state: Node = root.get_node("GameState") + state.buildings.clear() + state.extractors.clear() + state.transporters.clear() + state.stored_vessels.clear() + state.reset_progression() + state.rocks = 0 + state.gas = 30 + state.build_time_scale = 0.01 + + state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2.ZERO) + var lab_id: int = state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(120, 0)) + if not state.queue_lab_research(lab_id, "unlock_gas_bomber"): + _fail("could not queue gas research") + return + if state.outstanding_resource_cost(lab_id, GameState.RESOURCE_GAS) != 30: + _fail("lab did not request 30 gas") + return + + # Large steps make each physical leg complete in one call; multiple calls + # still exercise dispatch -> bank pickup -> lab delivery -> hub return. + for i in range(12): + state._advance_transporters(10.0) + state._advance_lab_research(10.0) + if not state.vessel_is_unlocked(GameState.VESSEL_GAS_BOMBER): + _fail("hub transporters did not deliver gas to research") + return + if state.gas != 0 or state.building_resource(lab_id, GameState.RESOURCE_GAS) != 0: + _fail("gas was not consumed at the lab") + return + print("TEST_MULTI_RESOURCE_SUPPLY: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_MULTI_RESOURCE_SUPPLY: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_multi_resource_supply.gd.uid b/tests/test_multi_resource_supply.gd.uid new file mode 100644 index 0000000..34ab26f --- /dev/null +++ b/tests/test_multi_resource_supply.gd.uid @@ -0,0 +1 @@ +uid://d34djd8v1r1jd diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index 20a2d6b..e11811f 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -23,7 +23,7 @@ func _process(delta: float) -> bool: return false var definitions: Array = VESSEL_DEFINITIONS.all() var fleet_definitions: Array = VESSEL_DEFINITIONS.fleet() - if fleet_definitions.size() != 2 \ + if fleet_definitions.size() != 3 \ or fleet_definitions.any(func(definition): return definition.has_capability("transport")): return _fail("logistics transporters leaked into the combat-fleet catalog") if ships.size() != definitions.size(): @@ -45,7 +45,13 @@ func _process(delta: float) -> bool: or definitions[1].hull_points.size() != 6 or definitions[1].icon != IconButton.Icon.SHIELD_VESSEL): return _fail("shield vessel is not an unarmed hexagon") - if ships[2].has_capability("weapon") or not ships[2].has_capability("transport"): + var gas_index: int = VESSEL_DEFINITIONS.ORDER.find(GameState.VESSEL_GAS_BOMBER) + if gas_index < 0 or ships[gas_index].has_capability("weapon") \ + or not ships[gas_index].definition.has_capability("gas_bomber"): + return _fail("gas bomber is not an unarmed skill vessel") + var transporter_index: int = VESSEL_DEFINITIONS.ORDER.find(GameState.VESSEL_TRANSPORTER) + if ships[transporter_index].has_capability("weapon") \ + or not ships[transporter_index].has_capability("transport"): return _fail("transporter capabilities were composed incorrectly") print("TEST_VESSEL_COMPOSITION: PASS") quit(0)