Expand and stage boss encounter
This commit is contained in:
parent
c325501999
commit
66b314f51a
17 changed files with 485 additions and 64 deletions
25
tests/_boss_approach_preview.gd
Normal file
25
tests/_boss_approach_preview.gd
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
extends Node2D
|
||||
|
||||
# Stable portrait view of #189's pre-fight state: the landed fleet is still
|
||||
# below the unseen Warden, combat HUD is absent, and only the edge indicator
|
||||
# gives away what is pulling the fleet upward.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.reset_new_game()
|
||||
GameState.intro_active = false
|
||||
GameState.reset_quests(false)
|
||||
GameState.planet_name = "Many-Eyed Deep"
|
||||
GameState.planet_id = "preview:boss:approach"
|
||||
GameState.planet_definition_id = "boss"
|
||||
GameState.surviving_ship_count = 3
|
||||
GameState.surviving_vessel_types = PackedStringArray([
|
||||
GameState.VESSEL_INTERCEPTOR,
|
||||
GameState.VESSEL_GAS_BOMBER,
|
||||
GameState.VESSEL_LASER,
|
||||
])
|
||||
var surface: Node = load("res://surface/PlanetSurface.tscn").instantiate()
|
||||
add_child(surface)
|
||||
surface.get_node("FadeOverlay/FadeRect").visible = false
|
||||
# Four screenshot frames are nowhere near enough for the automatic
|
||||
# approach to cover 950 world units, so the signal remains off-screen.
|
||||
1
tests/_boss_approach_preview.gd.uid
Normal file
1
tests/_boss_approach_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c50xlb8b4u84r
|
||||
6
tests/_boss_approach_preview.tscn
Normal file
6
tests/_boss_approach_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_boss_approach_preview.gd" id="1"]
|
||||
|
||||
[node name="BossApproachPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -33,6 +33,11 @@ func _process(_delta: float) -> void:
|
|||
laid_out = true
|
||||
var encounter: Node = surface.get_node("SurfaceFeatures/BossEncounter")
|
||||
var boss: Node2D = encounter.get_node("Boss")
|
||||
encounter.begin_reveal()
|
||||
encounter._process(encounter.REVEAL_SECONDS + 0.1)
|
||||
var fleet: Node2D = surface.get_node("Fleet")
|
||||
fleet.teleport_to(boss.global_position + Vector2(0.0, 780.0))
|
||||
surface.get_node("Camera2D").global_position = fleet.get_centroid()
|
||||
boss.wave_timer = 999.0
|
||||
boss.laser_timer = 999.0
|
||||
for i in range(3):
|
||||
|
|
@ -44,6 +49,8 @@ func _process(_delta: float) -> void:
|
|||
boss.open_next_eye()
|
||||
boss.spawn_wave()
|
||||
for minion in get_tree().get_nodes_in_group("boss_minions"):
|
||||
# Let the authored expulsion fan complete before freezing the gallery.
|
||||
minion._physics_process(0.65)
|
||||
minion.speed = 0.0
|
||||
minion.acceleration = 0.0
|
||||
boss.laser_charge_time = 999.0
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ extends SceneTree
|
|||
# scripted profile is excluded from the map editor
|
||||
# B. landing composes BossEncounter instead of a PlanetSetup and does not
|
||||
# mark the world conquered for free
|
||||
# C. phase one spawns an octopus wave with exactly one marked hatchling
|
||||
# D. killing the marked hatchling sends a visible return to the boss and
|
||||
# C. the off-screen approach locks movement and reveals with its own cue
|
||||
# D. waves are larger, expelled outward, varied, and golden only 1/3 times
|
||||
# E. killing the marked hatchling sends a visible return to the boss and
|
||||
# opens an attackable tentacle eye
|
||||
# E. three destroyed eyes start the three-lane laser phase
|
||||
# F. destroying every eye defeats the boss, persists conquest, and a second
|
||||
# landing does not resurrect it
|
||||
# F. three destroyed eyes start the three-lane laser phase
|
||||
# G. eyes expose a separate core; destroying it persists conquest, and a
|
||||
# second landing does not resurrect the boss
|
||||
|
||||
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||
const BOSS_SCRIPT = preload("res://boss/OctopusBoss.gd")
|
||||
|
|
@ -110,6 +111,37 @@ func _process(delta: float) -> bool:
|
|||
if surface.get_node("UI/CaptureLabel").visible:
|
||||
return _fail("ordinary capture-point status is visible during the boss encounter")
|
||||
boss = encounter.get_node("Boss")
|
||||
if not (boss is Area2D) or boss.get_node_or_null("CoreCollision") == null:
|
||||
return _fail("the boss core has no projectile collision target")
|
||||
if boss.position.y > -2400.0 or boss.scale.x < 2.7:
|
||||
return _fail("the boss is not far above the landing point and at least twice its old size")
|
||||
if encounter.health_bar.visible or encounter.status_label.visible:
|
||||
return _fail("boss combat UI is visible before the boss enters view")
|
||||
if not encounter.indicator.visible or not encounter.indicator_label.visible:
|
||||
return _fail("the off-screen boss has no directional border indicator")
|
||||
if not state.encounter_locks_movement or boss.combat_active:
|
||||
return _fail("the approach did not take temporary control or already started combat")
|
||||
# Put the camera where the automatic approach eventually carries it;
|
||||
# the encounter itself must detect the boss crossing into view.
|
||||
surface.get_node("Camera2D").global_position = boss.global_position + Vector2(0.0, 1000.0)
|
||||
encounter._process(0.0)
|
||||
if not encounter.reveal_started:
|
||||
return _fail("bringing the boss into view did not trigger its reveal")
|
||||
if not encounter.health_bar.visible or encounter.indicator.visible:
|
||||
return _fail("the reveal did not exchange the indicator for the health UI")
|
||||
var scream_heard: bool = false
|
||||
var sfx_pool: Node = root.get_node_or_null(Sfx.NODE_NAME)
|
||||
if sfx_pool != null:
|
||||
for voice in sfx_pool.get_children():
|
||||
if voice.stream != null and voice.stream.resource_path.ends_with("sfx_boss_scream.wav"):
|
||||
scream_heard = true
|
||||
if not scream_heard:
|
||||
return _fail("the on-screen reveal played no boss scream")
|
||||
encounter._process(encounter.REVEAL_SECONDS + 0.1)
|
||||
if not encounter.encounter_active or not boss.combat_active \
|
||||
or state.encounter_locks_movement:
|
||||
return _fail("the reveal did not start combat and return fleet control")
|
||||
print("off-screen signal led into a roar, shake, health reveal and player control")
|
||||
boss.wave_timer = 999.0
|
||||
boss.laser_timer = 999.0
|
||||
var health_bar: ProgressBar = encounter.get_node("EncounterUI/HealthBar")
|
||||
|
|
@ -123,14 +155,17 @@ func _process(delta: float) -> bool:
|
|||
if eye.opened or eye.is_in_group(COMBAT_TARGET.GROUP):
|
||||
return _fail("a tentacle eye starts open and attackable")
|
||||
|
||||
# --- C. phase-one wave ----------------------------------------
|
||||
# --- D. larger, varied, one-in-three waves --------------------
|
||||
boss.spawn_wave()
|
||||
var minions: Array = get_nodes_in_group("boss_minions")
|
||||
if minions.size() != boss.minions_per_wave:
|
||||
return _fail("phase one spawned %d hatchlings, expected %d" \
|
||||
% [minions.size(), boss.minions_per_wave])
|
||||
var marked: Node = null
|
||||
var dashers: int = 0
|
||||
for minion in minions:
|
||||
if minion.uses_dash_pattern:
|
||||
dashers += 1
|
||||
if minion.marked:
|
||||
if marked != null:
|
||||
return _fail("one wave contains more than one marked hatchling")
|
||||
|
|
@ -139,6 +174,25 @@ func _process(delta: float) -> bool:
|
|||
minion.take_damage(999)
|
||||
if marked == null:
|
||||
return _fail("phase-one wave has no marked hatchling")
|
||||
if minions.size() < 7 or dashers < 2:
|
||||
return _fail("the boss wave is not larger and behaviorally mixed")
|
||||
var launch_distance: float = marked.global_position.distance_to(boss.global_position)
|
||||
var launch_velocity: Vector2 = marked.velocity
|
||||
if launch_distance <= boss.BODY_RADIUS * boss.scale.x \
|
||||
or launch_velocity.dot(marked.global_position - boss.global_position) <= 0.0:
|
||||
return _fail("a hatchling was not expelled outward from the boss")
|
||||
marked._physics_process(0.4)
|
||||
if marked.global_position.distance_to(boss.global_position) <= launch_distance + 100.0:
|
||||
return _fail("the outward launch did not spread a hatchling before it targeted the fleet")
|
||||
marked._physics_process(1.0)
|
||||
marked.dash_cooldown = 0.0
|
||||
marked._physics_process(0.01)
|
||||
if marked.motion != marked.Motion.BRAKING:
|
||||
return _fail("a dash-pattern hatchling never stops before lunging")
|
||||
marked._physics_process(marked.BRAKE_SECONDS + 0.01)
|
||||
marked._physics_process(0.2)
|
||||
if marked.motion != marked.Motion.DASHING or marked.velocity.length() <= marked.speed:
|
||||
return _fail("a stopped hatchling did not accelerate into its random dash")
|
||||
if marked.max_hp < 5:
|
||||
return _fail("boss hatchlings still have only %d health" % marked.max_hp)
|
||||
marked.take_damage(1)
|
||||
|
|
@ -151,7 +205,20 @@ func _process(delta: float) -> bool:
|
|||
if returns.size() != 1:
|
||||
return _fail("marked death launched %d returns, expected one" % returns.size())
|
||||
returns[0].speed = 4000.0
|
||||
print("phase one spawned a wave; the marked kill returned toward the boss")
|
||||
for expected_marked in [0, 0, 1]:
|
||||
boss.spawn_wave()
|
||||
var spawned: Array = get_nodes_in_group("boss_minions")
|
||||
var marked_count: int = 0
|
||||
for minion in spawned:
|
||||
if minion.is_queued_for_deletion():
|
||||
continue
|
||||
if minion.marked:
|
||||
marked_count += 1
|
||||
minion.retire()
|
||||
if marked_count != expected_marked:
|
||||
return _fail("golden cadence produced %d marked hatchlings, expected %d" \
|
||||
% [marked_count, expected_marked])
|
||||
print("larger waves launch outward with dashers; only one of three is golden")
|
||||
stage = 1
|
||||
elapsed = 0.0
|
||||
return false
|
||||
|
|
@ -180,6 +247,8 @@ func _process(delta: float) -> bool:
|
|||
eye.take_damage(999)
|
||||
if boss.eyes_destroyed != 1:
|
||||
return _fail("destroying the first eye did not damage the boss")
|
||||
if boss.max_health() - boss.current_health() >= boss.max_health() / boss.EYE_COUNT:
|
||||
return _fail("one eye still removes a full sixth of the total encounter health")
|
||||
print("return opened a tentacle eye; the eye accepted damage and broke")
|
||||
_open_return()
|
||||
stage = 2
|
||||
|
|
@ -224,15 +293,6 @@ func _process(delta: float) -> bool:
|
|||
return _fail("boss laser extends backwards through its source")
|
||||
beam.queue_free()
|
||||
boss.spawn_wave()
|
||||
var phase_two_minions: Array = get_nodes_in_group("boss_minions")
|
||||
var phase_two_marked: int = 0
|
||||
for minion in phase_two_minions:
|
||||
if minion.marked:
|
||||
phase_two_marked += 1
|
||||
minion.retire()
|
||||
if phase_two_minions.size() != 3 or phase_two_marked != 1:
|
||||
return _fail("laser phase no longer supplies one marked target to open remaining eyes")
|
||||
boss.spawn_wave()
|
||||
boss.spawn_wave()
|
||||
boss.spawn_wave()
|
||||
if boss.spawned_minions.size() > boss.MAX_ACTIVE_MINIONS:
|
||||
|
|
@ -242,18 +302,29 @@ func _process(delta: float) -> bool:
|
|||
minion.retire()
|
||||
print("three broken eyes started a telegraphed three-lane laser phase")
|
||||
|
||||
# --- F. all eyes -> persistent victory ------------------------
|
||||
# --- G. all eyes -> exposed core -> persistent victory --------
|
||||
for i in range(3, boss.EYE_COUNT):
|
||||
boss.eyes[i].open_eye()
|
||||
boss.eyes[i].take_damage(999)
|
||||
if boss.phase != BOSS_SCRIPT.Phase.CORE_EXPOSED or not boss.visible:
|
||||
return _fail("breaking every eye did not expose a still-living core")
|
||||
if state.is_planet_conquered(state.planet_id):
|
||||
return _fail("breaking the eyes conquered the planet before the core died")
|
||||
if not boss.is_in_group(COMBAT_TARGET.GROUP):
|
||||
return _fail("the exposed core is not an attackable combat target")
|
||||
var core_before: int = boss.core_health
|
||||
boss.take_damage(1)
|
||||
if boss.core_health != core_before - 1 or boss.phase == BOSS_SCRIPT.Phase.DEFEATED:
|
||||
return _fail("the exposed core did not provide a separate final health pool")
|
||||
boss.take_damage(999)
|
||||
if boss.phase != BOSS_SCRIPT.Phase.DEFEATED or boss.visible:
|
||||
return _fail("breaking every eye did not defeat and hide the boss")
|
||||
return _fail("destroying the core did not defeat and hide the boss")
|
||||
if not state.is_planet_conquered(state.planet_id):
|
||||
return _fail("boss victory did not persist through planet conquest state")
|
||||
if encounter.health_bar.value != 0 \
|
||||
or encounter.health_bar.max_value != boss.max_health():
|
||||
return _fail("boss health bar did not end empty with a stable total")
|
||||
print("all eyes broken: boss defeated and planet conquest persisted")
|
||||
print("all eyes exposed a durable core; destroying it persisted planet conquest")
|
||||
|
||||
surface.free()
|
||||
surface = load("res://surface/PlanetSurface.tscn").instantiate()
|
||||
|
|
|
|||
Loading…
Reference in a new issue