diff --git a/Asteroid.gd b/Asteroid.gd index eaac5c2..1042622 100644 --- a/Asteroid.gd +++ b/Asteroid.gd @@ -1,7 +1,30 @@ extends Area2D +# A drifting rock that can be shot apart for the ore inside it. +# +# Asteroids used to be pure hazard: they could only ever cost you a ship. Now +# they are also the one resource you can reach without conquering anything, +# which is what the fleet's guns are for out in open space. +# +# Two ways to destroy one, and they are deliberately NOT the same: +# - shot apart, which yields the ore +# - flown into, which does not. The rock shatters against a hull instead of +# being mined, and you have just lost a ship (or a shield charge) for it. +# Without that split, a shield vessel could farm asteroids by ramming. + +const ROCK_SCENE: PackedScene = preload("res://Rock.tscn") +# Shots needed to crack one open. Low enough that a single vessel clears one +# in a couple of bursts, high enough that a stray shot in passing does not. +const HIT_POINTS: int = 2 +# How long the dropped ore survives if nobody comes for it. Surface rocks live +# forever because a planet surface is bounded and hand-authored; open space is +# not, and an asteroid field shot up and left behind would otherwise leak +# nodes for the rest of the session. +const DROP_LIFETIME: float = 60.0 + var velocity: Vector2 = Vector2.ZERO var rotation_speed: float = 0.0 +var hp: int = HIT_POINTS var shape_points: PackedVector2Array = PackedVector2Array() @@ -30,10 +53,41 @@ func _process(delta: float) -> void: rotation += rotation_speed * delta +# Same contract as Enemy.take_damage, so every weapon that already knows how +# to hurt an enemy can hurt an asteroid without learning a second protocol. +func take_damage(amount: int) -> bool: + hp -= maxi(amount, 1) + if hp > 0: + return false + shatter(true) + return true + + +func shatter(drop_ore: bool) -> void: + AsteroidExplosionVFX.spawn(get_tree().root, position) + if drop_ore: + _drop_ore() + queue_free() + + +func _drop_ore() -> void: + var rock: Node2D = ROCK_SCENE.instantiate() + rock.build_shape(randi()) + rock.position = position + rock.lifetime = DROP_LIFETIME + # Inherits the asteroid's drift, so the ore carries on along the path the + # rock it came from was already travelling instead of stopping dead in + # space - which reads as debris rather than as a spawned pickup. + rock.velocity = velocity + # Deferred: this runs from inside an area_entered signal, where the + # physics server is mid-flush and refuses a direct add_child. + get_parent().add_child.call_deferred(rock) + + func _on_area_entered(area: Area2D) -> void: if area.is_in_group("player_ships"): - AsteroidExplosionVFX.spawn(get_tree().root, position) - queue_free() + # No ore: see the note at the top of this file. + shatter(false) func _draw() -> void: diff --git a/GasBombController.gd b/GasBombController.gd index f25c3cb..975bea8 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -85,7 +85,10 @@ func _advance_effects(delta: float) -> void: func _explode(at: Vector2) -> void: explosions.append({"position": at, "time": 0.8}) last_hit_count = 0 - for enemy in get_tree().get_nodes_in_group("enemies"): + # Asteroids too - a blast that clears everything around it but leaves the + # rocks untouched would need explaining, and they take damage the same way. + for enemy in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids"): if is_instance_valid(enemy) and enemy is Node2D \ and enemy.global_position.distance_to(at) <= AOE_RADIUS: enemy.take_damage(DAMAGE) diff --git a/LaserController.gd b/LaserController.gd index fd66e30..974fe52 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -73,7 +73,11 @@ func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> voi beams.append({"from": from, "to": to, "time": FLASH_DURATION}) last_hit_count = 0 var range_along: float = from.distance_to(to) - for enemy in get_tree().get_nodes_in_group("enemies"): + # Asteroids share the enemies' take_damage() contract, so the beam sweeps + # both groups in one pass. Shooting a path clear through a field is a + # perfectly good use of a laser, and the ore it leaves behind is the point. + for enemy in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids"): if not is_instance_valid(enemy) or not (enemy is Node2D): continue var to_enemy: Vector2 = enemy.global_position - from diff --git a/Projectile.gd b/Projectile.gd index ba7d629..92fcf7a 100644 --- a/Projectile.gd +++ b/Projectile.gd @@ -34,7 +34,9 @@ func _physics_process(delta: float) -> void: func _on_area_entered(area: Area2D) -> void: - if area.is_in_group("enemies"): + # Asteroids answer take_damage() exactly as enemies do, so both are hit by + # the same branch rather than by a second special case. + if area.is_in_group("enemies") or area.is_in_group("asteroids"): area.take_damage(damage) queue_free() diff --git a/Rock.gd b/Rock.gd index e84a1fe..690fa84 100644 --- a/Rock.gd +++ b/Rock.gd @@ -4,11 +4,11 @@ extends Area2D # up by flying any ship over it; the fleet doesn't need to stop. # # Deliberately drawn as a PERFECT HEXAGON rather than the irregular lumpy -# polygon Asteroid.gd uses. The two are never on screen -# together (asteroids are space-only, rocks surface-only), so the player -# can only tell "collect this" from "this hurts" by learned shape language: -# irregular and jagged reads as a natural hazard, regular and faceted reads -# as something valuable and meant to be picked up. +# polygon Asteroid.gd uses. The two now DO appear together - shooting an +# asteroid drops one of these right next to its neighbours - which makes the +# shape language matter more than when they were kept apart by scene: jagged +# and irregular reads as a natural hazard, regular and faceted reads as +# something valuable and meant to be picked up. # # It also drifts toward a nearby ship (see _physics_process). Nothing # harmful moves helpfully toward you, so the pull doubles as a readability @@ -16,6 +16,13 @@ extends Area2D signal collected +# Seconds before an uncollected rock disappears. 0 means never, which is what +# hand-placed surface rocks use - a planet surface is bounded and authored, so +# nothing accumulates. Ore dropped by a shot asteroid sets a real lifetime, +# because open space is unbounded and a field shot up and left behind would +# otherwise leak nodes for the rest of the session. +@export var lifetime: float = 0.0 + @export var attract_radius: float = 220.0 @export var attract_speed: float = 260.0 @export var attract_accel: float = 420.0 @@ -24,6 +31,7 @@ var shape_points: PackedVector2Array = PackedVector2Array() var facet_points: PackedVector2Array = PackedVector2Array() var size_scale: float = 1.0 var velocity: Vector2 = Vector2.ZERO +var age: float = 0.0 func _ready() -> void: @@ -56,6 +64,12 @@ func build_shape(shape_seed: int) -> void: func _physics_process(delta: float) -> void: # Drift toward the closest ship inside attract_radius. No redraw here: # position is a transform property and the crystal's shape is fixed. + if lifetime > 0.0: + age += delta + if age >= lifetime: + queue_free() + return + var target: Node2D = _nearest_ship() if target == null: velocity = velocity.move_toward(Vector2.ZERO, attract_accel * delta) diff --git a/tests/test_asteroid_mining.gd b/tests/test_asteroid_mining.gd new file mode 100644 index 0000000..eb5c927 --- /dev/null +++ b/tests/test_asteroid_mining.gd @@ -0,0 +1,166 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_asteroid_mining.gd +# Asteroids used to be pure hazard - the only thing one could ever do was cost +# you a ship. They can now be shot apart for the ore inside, which is the one +# resource reachable in open space without conquering anything. +# +# Covers: +# A. an asteroid survives a hit it should survive, so "destructible" does +# not mean "evaporates on contact with anything" +# B. enough fire destroys it and leaves ore behind +# C. the ore is fetchable - flying a ship over it loads the fleet's hold, +# which is the whole point of dropping it +# D. ramming destroys the asteroid but yields NOTHING. Without that split a +# shield vessel could farm asteroids by flying into them +# E. dropped ore expires if nobody collects it, so a field that gets shot up +# and abandoned cannot leak nodes for the rest of the session; a +# hand-placed surface rock still lives forever + +var game_state: Node +var main: Node +var fleet: Node +var asteroid: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var hold_before: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + + +func _spawn_asteroid(at: Vector2) -> Node2D: + var rock: Node2D = load("res://Asteroid.tscn").instantiate() + rock.position = at + main.add_child(rock) + return rock + + +func _clear_sky() -> void: + for node in get_nodes_in_group("asteroids") + get_nodes_in_group("rocks") \ + + get_nodes_in_group("enemies") + get_nodes_in_group("player_projectiles"): + if is_instance_valid(node): + node.free() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + _clear_sky() + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + + # --- A/B. it takes real damage, then breaks ------------------- + asteroid = _spawn_asteroid(Vector2(4000.0, 0.0)) + var hit_points: int = int(asteroid.hp) + if hit_points < 2: + return _fail("an asteroid dies to a single point of damage, so there is nothing to survive") + for i in range(hit_points - 1): + asteroid.take_damage(1) + if not is_instance_valid(asteroid) or asteroid.is_queued_for_deletion(): + return _fail("the asteroid broke after %d of its %d hit points" % [hit_points - 1, hit_points]) + print("asteroid survived %d of %d hit points" % [hit_points - 1, hit_points]) + + asteroid.take_damage(1) + if is_instance_valid(asteroid) and not asteroid.is_queued_for_deletion(): + return _fail("the asteroid survived its full %d hit points" % hit_points) + stage = 1 + elapsed = 0.0 + return false + 1: + # The drop is deferred - it is created from inside a signal, where + # the physics server refuses a direct add_child. + if elapsed < 0.2: + return false + var dropped: Array = get_nodes_in_group("rocks") + if dropped.is_empty(): + return _fail("destroying an asteroid left no ore behind") + print("destroyed asteroid dropped %d ore node(s)" % dropped.size()) + + # --- C. the ore is fetchable --------------------------------- + hold_before = game_state.fleet_resource(GameState.RESOURCE_ORE) + var ship: Node2D = fleet.ships[0] + # Placed just clear of the ship rather than on top of it, so the + # rock's own drift has to bring it in - that pull is how a player + # actually collects one. + dropped[0].position = ship.global_position + Vector2(80.0, 0.0) + stage = 2 + elapsed = 0.0 + return false + 2: + if elapsed < 1.5: + return false + var gained: int = game_state.fleet_resource(GameState.RESOURCE_ORE) - hold_before + if gained <= 0: + return _fail("flying over the dropped ore put nothing in the hold") + print("dropped ore collected into the fleet hold (+%d)" % gained) + + # --- D. ramming yields nothing ------------------------------- + _clear_sky() + hold_before = game_state.fleet_resource(GameState.RESOURCE_ORE) + asteroid = _spawn_asteroid(fleet.ships[0].global_position) + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 0.5: + return false + if is_instance_valid(asteroid) and not asteroid.is_queued_for_deletion(): + return _fail("an asteroid dropped onto a ship did not shatter against it") + if not get_nodes_in_group("rocks").is_empty(): + return _fail("ramming an asteroid yielded ore - a shield vessel could farm them that way") + if game_state.fleet_resource(GameState.RESOURCE_ORE) != hold_before: + return _fail("ramming an asteroid changed the hold") + print("ramming shattered the asteroid and yielded nothing") + + # --- E. abandoned ore expires, authored ore does not ---------- + _clear_sky() + var abandoned: Node2D = load("res://Rock.tscn").instantiate() + abandoned.build_shape(1) + abandoned.lifetime = 0.4 + # Far from every ship, or it would be collected rather than expire. + abandoned.position = fleet.get_centroid() + Vector2(9000.0, 0.0) + main.add_child(abandoned) + var authored: Node2D = load("res://Rock.tscn").instantiate() + authored.build_shape(2) + authored.position = fleet.get_centroid() + Vector2(9600.0, 0.0) + main.add_child(authored) + if authored.lifetime != 0.0: + return _fail("a plain rock has a lifetime of %.1f - surface rocks must live forever" % authored.lifetime) + stage = 4 + elapsed = 0.0 + return false + 4: + if elapsed < 1.0: + return false + var survivors: Array = get_nodes_in_group("rocks") + var names: Array = [] + for rock in survivors: + names.append(rock.name) + if survivors.size() != 1: + return _fail("expected only the authored rock to remain, found %d: %s" % [survivors.size(), str(names)]) + print("abandoned ore expired; the rock with no lifetime is still there") + + print("TEST_ASTEROID_MINING: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ASTEROID_MINING: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index 8ba50e7..53e9a4e 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -91,6 +91,14 @@ func _process(delta: float) -> bool: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) + # Asteroids are shootable now, and the laser and the bomb both + # sweep them alongside enemies - so whatever the field already + # spawned would be counted as a hit. Silencing the spawner is not + # enough; what it placed before that has to go too. Same + # clean-slate idiom as test_ship_lab.gd. + for hazard in get_nodes_in_group("asteroids"): + if is_instance_valid(hazard): + hazard.free() if main.get_node("UI/GasBombButton").visible: return _fail_now("bomb control appeared without a Gas Bomber") fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ diff --git a/tests/test_gas_bomber_ammo.gd b/tests/test_gas_bomber_ammo.gd index 54aa122..7ef5ae6 100644 --- a/tests/test_gas_bomber_ammo.gd +++ b/tests/test_gas_bomber_ammo.gd @@ -57,6 +57,14 @@ func _process(delta: float) -> bool: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) + # Asteroids are shootable now, and the laser and the bomb both + # sweep them alongside enemies - so whatever the field already + # spawned would be counted as a hit. Silencing the spawner is not + # enough; what it placed before that has to go too. Same + # clean-slate idiom as test_ship_lab.gd. + for hazard in get_nodes_in_group("asteroids"): + if is_instance_valid(hazard): + hazard.free() # An interceptor rides along purely for its "cargo" capability # (#56): a fleet made only of a gas bomber has zero cargo # capacity, which would silently clamp every fleet_gas_cargo diff --git a/tests/test_gas_bomber_cooldowns.gd b/tests/test_gas_bomber_cooldowns.gd index 968d0da..655c5f9 100644 --- a/tests/test_gas_bomber_cooldowns.gd +++ b/tests/test_gas_bomber_cooldowns.gd @@ -26,6 +26,11 @@ func _process(delta: float) -> bool: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) + # Asteroids take damage now, so anything the field already placed + # would soak a bomb meant for the enemies under test. + for hazard in get_nodes_in_group("asteroids"): + if is_instance_valid(hazard): + hazard.free() fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_GAS_BOMBER])) skill._process(0.0) diff --git a/tests/test_laser_ammo.gd b/tests/test_laser_ammo.gd index a38b9ea..7a89251 100644 --- a/tests/test_laser_ammo.gd +++ b/tests/test_laser_ammo.gd @@ -56,6 +56,14 @@ func _process(delta: float) -> bool: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) + # Asteroids are shootable now, and the laser and the bomb both + # sweep them alongside enemies - so whatever the field already + # spawned would be counted as a hit. Silencing the spawner is not + # enough; what it placed before that has to go too. Same + # clean-slate idiom as test_ship_lab.gd. + for hazard in get_nodes_in_group("asteroids"): + if is_instance_valid(hazard): + hazard.free() # 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 diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index d18c91c..55c3238 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -68,6 +68,14 @@ func _process(delta: float) -> bool: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) + # Asteroids are shootable now, and the laser and the bomb both + # sweep them alongside enemies - so whatever the field already + # spawned would be counted as a hit. Silencing the spawner is not + # enough; what it placed before that has to go too. Same + # clean-slate idiom as test_ship_lab.gd. + for hazard in get_nodes_in_group("asteroids"): + if is_instance_valid(hazard): + hazard.free() # --- A. absent, then appears ------------------------------------- if main.get_node("UI/LaserButton").visible: