diff --git a/tests/test_asteroid_ship_collision.gd b/tests/test_asteroid_ship_collision.gd index 37a6508..b8c539e 100644 --- a/tests/test_asteroid_ship_collision.gd +++ b/tests/test_asteroid_ship_collision.gd @@ -3,9 +3,15 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_asteroid_ship_collision.gd # Verifies that a ship touching an asteroid destroys BOTH the ship (removed -# from the fleet) and the asteroid itself (mutual destruction), each -# spawning its own coherent explosion VFX: light-burst blue for the ship, -# debris brown for the asteroid. +# from the fleet) and the asteroid itself (mutual destruction), each leaving +# its own coherent VFX behind. +# +# #130 changed what the ship leaves. Its death is a sequence now - the hull +# breaks away from the fleet, drifts clear, and explodes out there - so what +# exists on the frame of the collision is the WRECK, and the explosion comes +# later, somewhere else. That the wreck really does drift and really does go +# up is test_ship_death's job; what matters here is that the collision still +# ends the ship, ends the rock, and leaves each of them something to look at. const CHECK_DELAY: float = 0.05 @@ -55,7 +61,7 @@ func _check_results() -> void: var ship_vfx_ok: bool = false var asteroid_vfx_ok: bool = false for child in root.get_children(): - if child is ShipExplosionVFX: + if child is ShipWreckVFX: ship_vfx_ok = true elif child is AsteroidExplosionVFX: asteroid_vfx_ok = true diff --git a/tests/test_ship_death.gd b/tests/test_ship_death.gd new file mode 100644 index 0000000..6891abc --- /dev/null +++ b/tests/test_ship_death.gd @@ -0,0 +1,224 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_ship_death.gd +# Forgejo #130: "they would break away from the fleet by a few metres, and +# end up exploding a little further off, with a rather more explosive +# explosion than just a light animation." +# +# Three claims, and the first one is the one that could quietly break +# everything else: the wreck must be SCENERY. A dying ship kept alive to +# animate would still be an Area2D in the player_ships group with live +# weapon components - still shooting, still a target, still colliding - so +# the test checks that the fleet, the group and the node are all rid of it +# on the same frame they always were. +# +# Covers: +# A. the ship itself dies immediately: out of the fleet, out of the group, +# freed - exactly as before +# B. a wreck is left behind, and it travels AWAY from the fleet +# C. it explodes where it ended up, not where it was hit +# D. the explosion is a real one - and no quieter than the one an enemy +# gets, which is the comparison #130 is making +# E. #135 - the prologue hands over an interceptor even when the belt has +# taken every escort + +var game_state: Node +var scene: Node +var fleet: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var wreck: Node2D +var wreck_start: Vector2 = Vector2.ZERO +var fleet_centre: Vector2 = Vector2.ZERO +var last_wreck_at: Vector2 = Vector2.ZERO +var ships_before: int = 0 + + +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.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_SHIP_DEATH: FAIL (%s)" % message) + quit(1) + return true + + +func _wrecks() -> Array: + var out: Array = [] + for node in root.get_children(): + if node is ShipWreckVFX: + out.append(node) + return out + + +func _explosions() -> Array: + var out: Array = [] + for node in root.get_children(): + if node is ShipExplosionVFX: + out.append(node) + return out + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + # A wedge, so there is a formation to break away FROM. + fleet.spawn_ships(3, Vector2.ZERO, + PackedStringArray([GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR])) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if fleet.ships.size() != 3: + return _fail("wanted a fleet of 3 to kill one out of, got %d" % fleet.ships.size()) + fleet_centre = fleet.get_centroid() + var doomed: Node2D = fleet.ships[0] + wreck_start = doomed.position + var before: int = get_nodes_in_group("player_ships").size() + doomed.take_damage(1) + + # --- A. the ship is gone, on this frame ------------------ + if fleet.ships.size() != 2: + return _fail("a killed ship is still in the fleet (%d ships)" % fleet.ships.size()) + if is_instance_valid(doomed) and not doomed.is_queued_for_deletion(): + return _fail("a killed ship was not freed") + ships_before = before + print("the ship leaves the fleet and is freed on the frame it dies") + + # --- B. and leaves a wreck behind ------------------------ + var wrecks: Array = _wrecks() + if wrecks.size() != 1: + return _fail("%d wrecks left behind, expected 1" % wrecks.size()) + wreck = wrecks[0] + if wreck.position.distance_to(wreck_start) > 1.0: + return _fail("the wreck did not start where the ship died") + last_wreck_at = wreck.position + elapsed = 0.0 + stage = 3 + return false + 3: + # Halfway through the drift: still there, and further out. + if elapsed < ShipWreckVFX.DRIFT_SECONDS * 0.5: + return false + if not is_instance_valid(wreck): + return _fail("the wreck was gone before it had finished drifting") + # A frame has passed, so the deletion queue has been flushed: the + # dead ship is out of the group the enemies pick targets from. + if get_nodes_in_group("player_ships").size() != ships_before - 1: + return _fail("a killed ship is still in the player_ships group a frame later") + var travelled: float = wreck.position.distance_to(wreck_start) + if travelled < 40.0: + return _fail("the wreck drifted %.0fpx - it is meant to break away" % travelled) + # Away from the fleet, not through it. + if wreck.position.distance_to(fleet_centre) <= wreck_start.distance_to(fleet_centre): + return _fail("the wreck drifted towards the fleet, not away from it") + if wreck.rotation == 0.0: + return _fail("the wreck is not tumbling") + last_wreck_at = wreck.position + print("wreck drifted %.0fpx clear of the fleet, tumbling" % travelled) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < ShipWreckVFX.DRIFT_SECONDS: + return false + # --- C. it went up where it ended up --------------------- + if is_instance_valid(wreck) and not wreck.is_queued_for_deletion(): + return _fail("the wreck never exploded") + var blasts: Array = _explosions() + if blasts.is_empty(): + return _fail("the wreck vanished without an explosion") + var at: Vector2 = blasts[0].position + if at.distance_to(wreck_start) < 40.0: + return _fail("the explosion happened at the kill (%.0fpx away), not out where the wreck drifted to" \ + % at.distance_to(wreck_start)) + print("it exploded %.0fpx from where the ship was hit" % at.distance_to(wreck_start)) + + # --- D. and it is a real explosion ----------------------- + var ours: ShipExplosionVFX = ShipExplosionVFX.new() + var theirs: EnemyExplosionVFX = EnemyExplosionVFX.new() + if ours.shockwave_radius <= 0.0: + return _fail("a lost vessel still goes up without a shockwave") + if ours.particle_count < theirs.particle_count: + return _fail("losing a vessel (%d particles) is quieter than killing an enemy (%d)" \ + % [ours.particle_count, theirs.particle_count]) + if ours.lifetime < theirs.lifetime: + return _fail("a lost vessel's explosion is shorter than an enemy's") + print("player explosion: %d particles over %.2fs, shockwave %.0f (enemy: %d over %.2fs)" \ + % [ours.particle_count, ours.lifetime, ours.shockwave_radius, + theirs.particle_count, theirs.lifetime]) + ours.free() + theirs.free() + + # --- E. #135: the prologue's survivor -------------------- + scene.free() + game_state.reset_new_game() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.4: + return false + var director: Node2D = scene.get_node("IntroDirector") + var narration: Control = scene.get_node("UI/NarrationBanner") + narration.clear() + # The belt takes every escort on the way through - the corners of + # the convoy are the exposed part of it. This is the case #135 + # reported: what is left is all cargo. + for i in range(fleet.ships.size() - 1, -1, -1): + var ship: Node2D = fleet.ships[i] + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + fleet.ships.remove_at(i) + fleet.formation_offsets.remove_at(i) + ship.free() + if fleet.ships.is_empty(): + return _fail("the test emptied the convoy instead of stripping its escorts") + for ship in fleet.ships: + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + return _fail("an escort survived the test's own belt") + print("convoy stripped to %d holds and no escorts" % fleet.ships.size()) + + director._begin(director.Beat.VORTEX) + if not fleet.has_vessel_type(GameState.VESSEL_INTERCEPTOR): + return _fail("the vortex began with no basic vessel to leave behind") + if String(director._survivor().vessel_type) != GameState.VESSEL_INTERCEPTOR: + return _fail("the survivor is a %s" % director._survivor().vessel_type) + director.finish() + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("the prologue handed over %d vessels" % fleet.ships.size()) + if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR: + return _fail("the prologue handed over a %s, not the basic vessel" \ + % fleet.ships[0].vessel_type) + print("with every escort lost, the prologue still hands over an interceptor") + + print("TEST_SHIP_DEATH: PASS") + quit(0) + return true + return false diff --git a/tests/test_ship_death.gd.uid b/tests/test_ship_death.gd.uid new file mode 100644 index 0000000..7a8c72e --- /dev/null +++ b/tests/test_ship_death.gd.uid @@ -0,0 +1 @@ +uid://b13dmq47yfhjn diff --git a/vessels/Ship.gd b/vessels/Ship.gd index 6a15ac8..d98619b 100644 --- a/vessels/Ship.gd +++ b/vessels/Ship.gd @@ -170,12 +170,31 @@ func take_damage(_amount: int) -> bool: for child in get_children(): if child.has_method("absorb_hit") and child.absorb_hit(): return true - ShipExplosionVFX.spawn(get_tree().root, position) + # #130: the hull comes off the formation and explodes further out, rather + # than going up on the spot. The wreck is its own node - this one is + # still freed on this frame, so nothing about who is in the fleet, who + # can be shot, or who is a target changes. + ShipWreckVFX.spawn(get_tree().root, self, _breakaway_direction()) destroyed.emit() queue_free() return false +# Which way is "out of the fleet". Away from the formation's own centre, so a +# wreck leaves the wedge instead of tumbling back through it; a ship flying +# alone - or one not under a Fleet at all - simply carries on the way it was +# already pointed. +func _breakaway_direction() -> Vector2: + var parent: Node = get_parent() + if parent != null and parent.has_method("get_centroid"): + var out: Vector2 = position - (parent.get_centroid() as Vector2) + if out.length() > 1.0: + return out.normalized() + if velocity.length() > 1.0: + return velocity.normalized() + return Vector2.UP.rotated(rotation) + + func _on_area_entered(area: Area2D) -> void: if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): return diff --git a/vfx/ShipExplosionVFX.gd b/vfx/ShipExplosionVFX.gd index 789f956..317325c 100644 --- a/vfx/ShipExplosionVFX.gd +++ b/vfx/ShipExplosionVFX.gd @@ -3,7 +3,20 @@ extends LightBurstVFX func _init() -> void: + # #130: "a rather more explosive explosion than just a light animation". + # This used to take the base class's defaults - 12 particles, half a + # second, no shockwave - which made losing one of your own vessels a + # quieter event on screen than killing an enemy, whose explosion #87 + # had already been told to be the loudest thing in the frame. Now it is + # the louder of the two, which is the right way round: the enemy's is a + # reward, this one is a loss. color = Color(0.25, 0.85, 1.0) + particle_count = 30 + lifetime = 0.8 + min_speed = 150.0 + max_speed = 540.0 + shockwave_radius = 120.0 + shockwave_color = Color(0.6, 0.95, 1.0) static func spawn(parent: Node, world_position: Vector2) -> void: diff --git a/vfx/ShipWreckVFX.gd b/vfx/ShipWreckVFX.gd new file mode 100644 index 0000000..e827a7c --- /dev/null +++ b/vfx/ShipWreckVFX.gd @@ -0,0 +1,113 @@ +class_name ShipWreckVFX +extends Node2D + +# Forgejo #130: a vessel's death, as a short sequence rather than a moment. +# +# A ship used to vanish on the frame it was hit and leave a twelve-particle +# puff behind - lighter than the one an ENEMY gets (see EnemyExplosionVFX, +# which #87 made deliberately loud). Losing a hull is the more expensive +# event of the two and it read as the cheaper one. +# +# So the hull comes off the formation and keeps going: it breaks away from +# the fleet, tumbling and venting, for the length of DRIFT_SECONDS, and only +# then goes up - out where it has some room, rather than inside the wedge it +# was flying in. +# +# It is a separate node rather than the Ship staying alive a little longer, +# and that is the important part. A dying Ship still in the tree is still an +# Area2D in the player_ships group with live weapon components: it would keep +# shooting, keep being shot at, keep counting as a target, and keep colliding +# on its way out. Everything about the ship's own lifecycle - the `destroyed` +# signal, leaving Fleet.ships, freeing the node - happens exactly when it did +# before. What is left behind is scenery. + +const DRIFT_SECONDS: float = 0.62 +# How hard it is pushed out of the formation. Enough to clear the wedge in +# the time above, not so much that it reads as being thrown. +const BREAKAWAY_SPEED: float = 300.0 +const DRAG: float = 0.72 +const SPIN_MIN: float = 2.4 +const SPIN_MAX: float = 7.0 +const VENT_COUNT: int = 4 +const VENT_SPEED: float = 90.0 +# The hull darkens as it goes, so the drift reads as dying rather than as +# flying away. +const FADE_TO: float = 0.35 + +var hull: PackedVector2Array = PackedVector2Array() +var glow_color: Color = Color(0.25, 0.85, 1.0) +var core_color: Color = Color.WHITE +var velocity: Vector2 = Vector2.ZERO +var spin: float = 0.0 +var age: float = 0.0 +var vents: Array = [] +var _blown: bool = false + + +# `away` is the direction out of the formation, so the wreck leaves the fleet +# rather than drifting through the middle of it. +static func spawn(parent: Node, ship: Node2D, away: Vector2) -> ShipWreckVFX: + var wreck := ShipWreckVFX.new() + wreck.position = ship.position + wreck.rotation = ship.rotation + wreck.glow_color = ship.glow_color + wreck.core_color = ship.core_color + if ship.definition != null: + wreck.hull = ship.definition.hull_points + var drift: Vector2 = away if away.length() > 0.01 else Vector2.UP.rotated(ship.rotation) + # Its own momentum plus the push: a ship hit at speed carries on the way + # it was already going. + wreck.velocity = ship.velocity + drift.normalized() * BREAKAWAY_SPEED + wreck.spin = randf_range(SPIN_MIN, SPIN_MAX) * (1.0 if randf() < 0.5 else -1.0) + parent.add_child(wreck) + return wreck + + +func _ready() -> void: + if hull.is_empty(): + hull = PackedVector2Array([Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16)]) + for i in range(VENT_COUNT): + var angle: float = TAU * float(i) / float(VENT_COUNT) + randf() * 0.6 + vents.append({ + "pos": Vector2.ZERO, + "vel": Vector2(cos(angle), sin(angle)) * randf_range(VENT_SPEED * 0.4, VENT_SPEED), + "len": randf_range(9.0, 18.0), + }) + + +func _process(delta: float) -> void: + age += delta + if age >= DRIFT_SECONDS: + _blow_up() + return + position += velocity * delta + velocity = velocity.lerp(Vector2.ZERO, clampf(DRAG * delta, 0.0, 1.0)) + rotation += spin * delta + for vent in vents: + vent.pos += vent.vel * delta + vent.vel *= 0.94 + queue_redraw() + + +func _blow_up() -> void: + if _blown: + return + _blown = true + # Where it ended up, not where it was hit - the point of the drift. + if is_inside_tree(): + ShipExplosionVFX.spawn(get_tree().root, global_position) + queue_free() + + +func _draw() -> void: + var t: float = clampf(age / DRIFT_SECONDS, 0.0, 1.0) + var fade: float = lerpf(1.0, FADE_TO, t) + NeonDraw.draw_glowing_polygon(self, hull, Color(glow_color, fade), 1.0, + Color(core_color, fade)) + # What is coming out of it. Drawn in the wreck's own local space, so the + # vents tumble with the hull the way a breach in it would. + var vent_colour: Color = Color(1.0, 0.72, 0.35, (1.0 - t) * 0.9) + for vent in vents: + var dir: Vector2 = Vector2(vent.vel).normalized() + NeonDraw.draw_glowing_line(self, vent.pos, vent.pos + dir * float(vent.len), + vent_colour, 0.8, 0.4) diff --git a/vfx/ShipWreckVFX.gd.uid b/vfx/ShipWreckVFX.gd.uid new file mode 100644 index 0000000..1f69cec --- /dev/null +++ b/vfx/ShipWreckVFX.gd.uid @@ -0,0 +1 @@ +uid://c30rnnoa6fkcd diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index 97c8420..010b14f 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -166,7 +166,11 @@ func finish() -> void: enemy_spawner.set_process(true) asteroid_field.set_process(true) # Exactly a new game's opening position: one interceptor, nothing else. - if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + # + # #135: the COUNT is not the whole of that. One surviving carrier is also + # a fleet of one, and it is not the fleet a new game starts with - so the + # types are checked too, and anything else is replaced. + if not _is_starting_fleet(): fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, fleet.get_centroid(), PackedStringArray([GameState.VESSEL_INTERCEPTOR])) finished.emit() @@ -177,6 +181,37 @@ func skip() -> void: finish() +func _is_starting_fleet() -> bool: + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return false + for ship in fleet.ships: + if not is_instance_valid(ship) \ + or String(ship.vessel_type) != GameState.VESSEL_INTERCEPTOR: + return false + return true + + +# #135: the game starts from an interceptor, so an interceptor is what has to +# come out of the vortex. +# +# The convoy's four escorts fly the corners of the box, which is the most +# exposed part of it, and the debris belt can take all four on the way +# through. With none left, the only hulls the whirl could spare were holds - +# and the player flew out of the prologue in a carrier, which is the report. +# +# One is put back into the convoy here, at the top of the vortex, where a +# vessel appearing among ten being thrown around a black hole is not +# something anyone is going to catch. Doing it here rather than only in +# finish() means the ship the player watches survive is the ship they then +# fly, instead of one being swapped for the other at the end. +func _ensure_survivor_hull() -> void: + for ship in fleet.ships: + if is_instance_valid(ship) \ + and String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + return + fleet.add_ship(GameState.VESSEL_INTERCEPTOR) + + func _apply_convoy_formation() -> void: for i in range(mini(CONVOY_FORMATION.size(), GameState.formation.size())): GameState.formation[i] = CONVOY_FORMATION[i] @@ -255,6 +290,7 @@ func _begin(next: int) -> void: narration.say("Kestrel, your heading is - something is opening ahead of you -", "CONVOY CONTROL") Beat.VORTEX: vortex_time = 0.0 + _ensure_survivor_hull() narration.clear() Beat.AFTERMATH: GameState.intro_locks_movement = false