From c221584848d3930256fe16a027bfdda032655c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 23 Aug 2026 13:37:40 +0200 Subject: [PATCH] #99 (#97a): buildings have health, and can be destroyed Nothing can attack a structure that cannot be hurt, so this is the floor the rest of #97 stands on. max_hp is per definition - an extractor bolted to a rock is not the mothership - and the current value lives on the RECORD in GameState, not on the Building node. Main.tscn is torn down and rebuilt on every trip to a planet surface, so damage kept on a node would heal itself the moment the player landed. A record written before this carries no hp field at all. That reads as undamaged rather than as zero, or an existing save would open on a field of wrecks. The mothership cannot be destroyed - it floors at 1hp. The bank, the hangar and research all live there and a wiped fleet rebuilds there, so losing it would be a soft-lock with no route back. Keyed on the "bank" capability rather than on the type: whatever building holds the bank is the one that cannot be lost. Destroying a hub retires its transporters. Everything else keyed on a building id already copes with the building being gone - a transporter mid-delivery puts its cargo back rather than destroying it. Co-Authored-By: Claude Opus 5 --- buildings/Building.gd | 47 +++++ buildings/BuildingDefinition.gd | 4 + buildings/BuildingField.gd | 18 ++ buildings/CommandBuildingDefinition.tres | 1 + buildings/CrafterBuildingDefinition.tres | 1 + buildings/ExtractorBuildingDefinition.tres | 1 + buildings/GasBurnerBuildingDefinition.tres | 1 + buildings/MothershipBuildingDefinition.tres | 1 + buildings/ShipLabBuildingDefinition.tres | 1 + buildings/TransportHubBuildingDefinition.tres | 1 + tests/test_building_health.gd | 195 ++++++++++++++++++ vfx/BuildingExplosionVFX.gd | 22 ++ 12 files changed, 293 insertions(+) create mode 100644 tests/test_building_health.gd create mode 100644 vfx/BuildingExplosionVFX.gd diff --git a/buildings/Building.gd b/buildings/Building.gd index e710fc1..196fe52 100644 --- a/buildings/Building.gd +++ b/buildings/Building.gd @@ -14,6 +14,18 @@ var building_type: String: set(value): definition_id = value +# #99: drawn only once the structure has actually been hit. A full bar over +# every building would be permanent clutter saying nothing, and the fleet +# would be flying over a field of meters in peacetime. +const HEALTH_BAR_WIDTH: float = 74.0 +const HEALTH_BAR_HEIGHT: float = 7.0 +const HEALTH_BAR_OFFSET: float = -78.0 +const HEALTH_FULL_COLOR: Color = Color(0.45, 0.9, 0.55) +const HEALTH_HURT_COLOR: Color = Color(1.0, 0.35, 0.25) + +var _drawn_health: float = 1.0 + + func _ready() -> void: add_to_group("buildings") z_index = -1 @@ -24,6 +36,41 @@ func _ready() -> void: get_node("VisualHost").add_child(visual) +func health_fraction() -> float: + return GameState.building_health_fraction(building_id) if building_id != 0 else 1.0 + + +func is_damaged() -> bool: + return health_fraction() < 0.999 + + +func _process(_delta: float) -> void: + # Redraw only when the number moves. A building that is not being shot at + # costs nothing, which is the same bargain Ship.gd's constant glow makes. + var health: float = health_fraction() + if is_equal_approx(health, _drawn_health): + return + _drawn_health = health + queue_redraw() + + +func _draw() -> void: + if not is_damaged(): + return + # Above the structure rather than across it: the visual scenes all draw + # around their own origin, and a bar laid over one would be read as part + # of the building. + var health: float = health_fraction() + var left: float = -HEALTH_BAR_WIDTH * 0.5 + var top: float = HEALTH_BAR_OFFSET + draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT), + Color(0.02, 0.02, 0.06, 0.85)) + draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH * health, HEALTH_BAR_HEIGHT), + HEALTH_HURT_COLOR.lerp(HEALTH_FULL_COLOR, health)) + draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT), + Color(0.55, 0.78, 0.86, 0.55), false, 1.0) + + func available_actions() -> PackedStringArray: if visual == null: return PackedStringArray() diff --git a/buildings/BuildingDefinition.gd b/buildings/BuildingDefinition.gd index 7b3f1fd..00fb1ae 100644 --- a/buildings/BuildingDefinition.gd +++ b/buildings/BuildingDefinition.gd @@ -19,3 +19,7 @@ extends Resource @export var menu_cost_name: String @export_enum("space", "planet") var placement_mode: String = "space" @export var unique: bool = false +# #99: what a wave has to chew through to level this. Data rather than one +# constant for every structure - an extractor bolted to a rock is not the +# mothership. See GameState.damage_building(). +@export var max_hp: int = 40 diff --git a/buildings/BuildingField.gd b/buildings/BuildingField.gd index 79e8dfe..d950509 100644 --- a/buildings/BuildingField.gd +++ b/buildings/BuildingField.gd @@ -22,9 +22,27 @@ var spawned: Array = [] func _ready() -> void: + # #99: the record is the truth, so the node follows it out. Connected + # here rather than in Building.gd because the field owns `spawned` and + # would otherwise be left holding a freed node. + if not GameState.building_destroyed.is_connected(_on_building_destroyed): + GameState.building_destroyed.connect(_on_building_destroyed) rebuild_all() +func _on_building_destroyed(building_id: int, _type: String) -> void: + var kept: Array = [] + for b in spawned: + if not is_instance_valid(b): + continue + if int(b.building_id) == building_id: + BuildingExplosionVFX.spawn(get_tree().root, b.global_position) + b.queue_free() + continue + kept.append(b) + spawned = kept + + func rebuild_all() -> void: for b in spawned: if is_instance_valid(b): diff --git a/buildings/CommandBuildingDefinition.tres b/buildings/CommandBuildingDefinition.tres index 5d63505..e24d24c 100644 --- a/buildings/CommandBuildingDefinition.tres +++ b/buildings/CommandBuildingDefinition.tres @@ -18,3 +18,4 @@ map_phase = 0.785398 menu_button_name = "CommandButton" menu_label_name = "CommandLabel" menu_cost_name = "CommandCost" +max_hp = 70 diff --git a/buildings/CrafterBuildingDefinition.tres b/buildings/CrafterBuildingDefinition.tres index ae29ea1..f74a5da 100644 --- a/buildings/CrafterBuildingDefinition.tres +++ b/buildings/CrafterBuildingDefinition.tres @@ -17,3 +17,4 @@ map_radius = 7.0 menu_button_name = "CrafterButton" menu_label_name = "CrafterLabel" menu_cost_name = "CrafterCost" +max_hp = 90 diff --git a/buildings/ExtractorBuildingDefinition.tres b/buildings/ExtractorBuildingDefinition.tres index 32b30c1..6856648 100644 --- a/buildings/ExtractorBuildingDefinition.tres +++ b/buildings/ExtractorBuildingDefinition.tres @@ -14,3 +14,4 @@ menu_button_name = "ExtractorButton" menu_label_name = "ExtractorLabel" menu_cost_name = "ExtractorCost" placement_mode = "planet" +max_hp = 40 diff --git a/buildings/GasBurnerBuildingDefinition.tres b/buildings/GasBurnerBuildingDefinition.tres index 13adca4..0f7d813 100644 --- a/buildings/GasBurnerBuildingDefinition.tres +++ b/buildings/GasBurnerBuildingDefinition.tres @@ -32,3 +32,4 @@ map_phase = 0.0 menu_button_name = "GasBurnerButton" menu_label_name = "GasBurnerLabel" menu_cost_name = "GasBurnerCost" +max_hp = 60 diff --git a/buildings/MothershipBuildingDefinition.tres b/buildings/MothershipBuildingDefinition.tres index 3a23df3..c998b2f 100644 --- a/buildings/MothershipBuildingDefinition.tres +++ b/buildings/MothershipBuildingDefinition.tres @@ -19,3 +19,4 @@ menu_button_name = "MothershipButton" menu_label_name = "MothershipLabel" menu_cost_name = "MothershipCost" unique = true +max_hp = 240 diff --git a/buildings/ShipLabBuildingDefinition.tres b/buildings/ShipLabBuildingDefinition.tres index 4e78e24..16e2547 100644 --- a/buildings/ShipLabBuildingDefinition.tres +++ b/buildings/ShipLabBuildingDefinition.tres @@ -18,3 +18,4 @@ map_phase = -1.570796 menu_button_name = "ShipLabButton" menu_label_name = "ShipLabLabel" menu_cost_name = "ShipLabCost" +max_hp = 90 diff --git a/buildings/TransportHubBuildingDefinition.tres b/buildings/TransportHubBuildingDefinition.tres index 864763a..2f79a86 100644 --- a/buildings/TransportHubBuildingDefinition.tres +++ b/buildings/TransportHubBuildingDefinition.tres @@ -18,3 +18,4 @@ map_phase = 0.392699 menu_button_name = "TransportHubButton" menu_label_name = "TransportHubLabel" menu_cost_name = "TransportHubCost" +max_hp = 80 diff --git a/tests/test_building_health.gd b/tests/test_building_health.gd new file mode 100644 index 0000000..5bb4417 --- /dev/null +++ b/tests/test_building_health.gd @@ -0,0 +1,195 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_building_health.gd +# Forgejo #99 (#97a): structures can be hurt, and can come down. +# +# Health lives on the RECORD in GameState rather than on the Building node, +# because Main.tscn is destroyed and rebuilt on every trip to a planet +# surface - damage kept on a node would heal itself the moment the player +# landed. Most of this test therefore talks to GameState directly, and only +# the last case looks at what the scene does with it. +# +# Covers: +# A. every building definition declares a positive max_hp, and a fresh +# record starts at full health +# B. damage subtracts, reports survival, and enough of it destroys the +# record outright +# C. a record written before this existed reads as undamaged, not as +# rubble - an existing save must not open on a field of wrecks +# D. the mothership cannot be destroyed. The bank, the hangar and research +# all live there and a wiped fleet rebuilds there, so losing it would +# be a soft-lock; it bottoms out at 1hp instead +# E. destroying a transport hub takes its transporters with it, and +# nothing else keyed on the id is left pointing at a hole +# F. the Building node follows its record out of existence, and shows +# damage while it is still standing + +var game_state: Node +var scene: Node +var stage: int = 0 +var elapsed: float = 0.0 +var destroyed_events: Array = [] + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.rocks = 5000 + + +func _fail(message: String) -> bool: + print("TEST_BUILDING_HEALTH: FAIL (%s)" % message) + quit(1) + return true + + +func _run_state_cases() -> bool: + var definitions: Script = load("res://buildings/BuildingDefinitions.gd") + + # --- A. every structure declares what it can take ------------------- + for definition in definitions.all(): + if int(definition.max_hp) <= 0: + return _fail("'%s' has max_hp %d - it would be rubble the moment it was placed" \ + % [definition.definition_id, definition.max_hp]) + var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(300.0, 0.0)) + var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER) + if game_state.building_hp(crafter_id) != full: + return _fail("a new crafter opened at %d of %d hp" \ + % [game_state.building_hp(crafter_id), full]) + if not is_equal_approx(game_state.building_health_fraction(crafter_id), 1.0): + return _fail("a new crafter is not at full health") + print("%d definitions all declare max_hp; a new crafter starts at %d" \ + % [definitions.all().size(), full]) + + # --- B. damage, then death ------------------------------------------ + game_state.building_destroyed.connect(func(id: int, type: String): + destroyed_events.append({"id": id, "type": type})) + if game_state.damage_building(crafter_id, full - 1): + return _fail("one hit point short of dead reported the crafter destroyed") + if game_state.building_hp(crafter_id) != 1: + return _fail("hp is %d after taking max_hp - 1" % game_state.building_hp(crafter_id)) + if game_state.building_health_fraction(crafter_id) >= 0.5: + return _fail("a crafter on its last hit point still reads as healthy") + if not game_state.damage_building(crafter_id, 1): + return _fail("the last hit point did not destroy the crafter") + if not game_state.find_building(crafter_id).is_empty(): + return _fail("the destroyed crafter is still in GameState.buildings") + if destroyed_events.size() != 1 or int(destroyed_events[0].id) != crafter_id: + return _fail("building_destroyed did not fire once for the crafter") + # Fired AFTER the record went, so a listener sees the world without it. + print("a crafter takes %d damage and comes down, announcing itself" % full) + + # --- C. a record from before this existed --------------------------- + var legacy_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-300.0, 0.0)) + game_state.find_building(legacy_id).erase("hp") + var lab_full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_LAB) + if game_state.building_hp(legacy_id) != lab_full: + return _fail("a record with no hp field reads as %d, not the %d it should" \ + % [game_state.building_hp(legacy_id), lab_full]) + if game_state.damage_building(legacy_id, 1): + return _fail("one point of damage levelled a building carried over from an old save") + print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full) + + # --- D. the mothership survives anything ---------------------------- + var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + if not game_state.building_is_indestructible(game_state.find_building(mothership_id)): + return _fail("the mothership is not marked indestructible") + if game_state.damage_building(mothership_id, 999999): + return _fail("the mothership was destroyed - the bank, hangar and research went with it") + if game_state.building_hp(mothership_id) != 1: + return _fail("an overwhelmed mothership sits at %d hp, expected to floor at 1" \ + % game_state.building_hp(mothership_id)) + if game_state.destroy_building(mothership_id): + return _fail("destroy_building demolished the mothership outright") + if game_state.repair_building(mothership_id, 999999) <= 0: + return _fail("a battered mothership could not be repaired") + if game_state.building_hp(mothership_id) != game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP): + return _fail("repair did not take the mothership back to full") + print("the mothership floors at 1hp and repairs to full") + + # --- E. a hub takes its transporters with it ------------------------ + var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0)) + var hub_transporters: int = 0 + for transporter in game_state.transporters: + if int(transporter.get("hub_id", 0)) == hub_id: + hub_transporters += 1 + if hub_transporters <= 0: + return _fail("the hub provisioned no transporters, so this case proves nothing") + var others: int = game_state.transporters.size() - hub_transporters + if not game_state.destroy_building(hub_id): + return _fail("the hub could not be destroyed") + for transporter in game_state.transporters: + if int(transporter.get("hub_id", 0)) == hub_id: + return _fail("a transporter is still flying for a hub that no longer exists") + if game_state.transporters.size() != others: + return _fail("destroying the hub took %d unrelated transporters with it" \ + % (others - game_state.transporters.size())) + print("destroying a hub retires its %d transporters and no others" % hub_transporters) + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _run_state_cases(): + return true + # --- F. what the scene does with it ------------------------- + game_state.buildings.clear() + game_state.transporters.clear() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + var field: Node = scene.get_node("BuildingField") + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + var field: Node = scene.get_node("BuildingField") + var node: Node2D = null + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER: + node = b + if node == null: + return _fail("no crafter node was spawned") + if node.is_damaged(): + return _fail("a freshly placed crafter already shows damage") + game_state.damage_building(node.building_id, game_state.building_max_hp( + GameState.BUILDING_SHIP_CRAFTER) / 2) + node._process(0.0) + if not node.is_damaged(): + return _fail("a crafter at half health does not show it") + print("a half-wrecked crafter reads %.0f%%" % (node.health_fraction() * 100.0)) + game_state.damage_building(node.building_id, 999999) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.2: + return false + var field: Node = scene.get_node("BuildingField") + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER: + return _fail("the crafter node outlived the record it was built from") + print("the node follows its record out of existence") + + print("TEST_BUILDING_HEALTH: PASS") + quit(0) + return true + return false diff --git a/vfx/BuildingExplosionVFX.gd b/vfx/BuildingExplosionVFX.gd new file mode 100644 index 0000000..c594bd0 --- /dev/null +++ b/vfx/BuildingExplosionVFX.gd @@ -0,0 +1,22 @@ +class_name BuildingExplosionVFX +extends LightBurstVFX + + +func _init() -> void: + # #99: bigger and slower than a hull going up. A structure coming down is + # the largest thing the player can lose without losing the run, and it + # has to be legible from across the map rather than mistaken for another + # kill in the fight that caused it. + color = Color(1.0, 0.6, 0.2) + particle_count = 34 + lifetime = 1.05 + min_speed = 90.0 + max_speed = 520.0 + shockwave_radius = 190.0 + shockwave_color = Color(1.0, 0.78, 0.35) + + +static func spawn(parent: Node, world_position: Vector2) -> void: + var vfx: BuildingExplosionVFX = BuildingExplosionVFX.new() + vfx.position = world_position + parent.add_child(vfx)