Expand and stage boss encounter
This commit is contained in:
parent
c325501999
commit
66b314f51a
17 changed files with 485 additions and 64 deletions
|
|
@ -27,6 +27,7 @@ const SHIP_DEATH: String = "ship_death"
|
|||
const ENEMY_DEATH: String = "enemy_death"
|
||||
const BUILDING_DEATH: String = "building_death"
|
||||
const DIALOG: String = "dialog"
|
||||
const BOSS_SCREAM: String = "boss_scream"
|
||||
|
||||
const SOUNDS: Dictionary = {
|
||||
SHOT: "res://audio/sfx_shot.wav",
|
||||
|
|
@ -34,6 +35,7 @@ const SOUNDS: Dictionary = {
|
|||
ENEMY_DEATH: "res://audio/sfx_ship_death.wav",
|
||||
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
|
||||
DIALOG: "res://audio/sfx_dialog.wav",
|
||||
BOSS_SCREAM: "res://audio/sfx_boss_scream.wav",
|
||||
}
|
||||
|
||||
# Per-sound level. A shot is fired constantly and a death happens once, so
|
||||
|
|
@ -44,6 +46,7 @@ const LEVELS: Dictionary = {
|
|||
ENEMY_DEATH: -23.0,
|
||||
BUILDING_DEATH: -23.0,
|
||||
DIALOG: -19.0,
|
||||
BOSS_SCREAM: -13.0,
|
||||
}
|
||||
|
||||
# Shots vary a little in pitch so a stream of them does not turn into one
|
||||
|
|
|
|||
|
|
@ -183,9 +183,32 @@ def explosion(seconds, sub_from, sub_to, roar_seed, debris_level, drive):
|
|||
return [_saturate(v, drive) * _attack(i, 0.001) for i, v in enumerate(mixed)]
|
||||
|
||||
|
||||
def boss_scream(seconds=1.65):
|
||||
"""A huge synthetic creature waking: a rough falling throat tone with
|
||||
an unstable upper rasp. Long enough to carry the reveal shake, without
|
||||
borrowing a human/animal recording whose licence would need tracking."""
|
||||
n = int(RATE * seconds)
|
||||
throat = [0.0] * n
|
||||
phase = 0.0
|
||||
for i in range(n):
|
||||
t = i / n
|
||||
hz = 118.0 * (52.0 / 118.0) ** (t ** 0.72)
|
||||
hz *= 1.0 + 0.07 * math.sin(t * math.pi * 17.0)
|
||||
phase += 2.0 * math.pi * hz / RATE
|
||||
envelope = min(1.0, t / 0.06) * (1.0 - t) ** 0.75
|
||||
throat[i] = (math.sin(phase) * 0.75 + math.sin(phase * 2.03) * 0.32
|
||||
+ math.sin(phase * 3.97) * 0.16) * envelope
|
||||
rasp_noise = _noise(n, 189, lambda t: 0.34 * (1.0 - t) + 0.025)
|
||||
rasp = [value * min(1.0, i / (RATE * 0.025)) * _decay(i, n, 1.15) * 0.68
|
||||
for i, value in enumerate(rasp_noise)]
|
||||
mixed = _mix(throat, rasp)
|
||||
return [_saturate(value, 3.2) for value in mixed]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_write("sfx_shot", shot())
|
||||
_write("sfx_dialog", dialog())
|
||||
_write("sfx_boss_scream", boss_scream())
|
||||
# A vessel coming apart: bright, fast, with real low end under it.
|
||||
_write("sfx_ship_death", explosion(1.45, 260.0, 38.0, 5, 0.16, 2.6))
|
||||
# A structure: longer, deeper, and it keeps coming down afterwards.
|
||||
|
|
|
|||
BIN
audio/sfx_boss_scream.wav
Normal file
BIN
audio/sfx_boss_scream.wav
Normal file
Binary file not shown.
24
audio/sfx_boss_scream.wav.import
Normal file
24
audio/sfx_boss_scream.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://disto7xb3s8gx"
|
||||
path="res://.godot/imported/sfx_boss_scream.wav-bf123df4e039686a30aabf3e3bb9dd12.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_boss_scream.wav"
|
||||
dest_files=["res://.godot/imported/sfx_boss_scream.wav-bf123df4e039686a30aabf3e3bb9dd12.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
|
|
@ -2,11 +2,24 @@ class_name BossEncounter
|
|||
extends Node2D
|
||||
|
||||
@onready var boss: Node2D = $Boss
|
||||
@onready var fleet: Node2D = get_node("../../Fleet")
|
||||
@onready var camera: Camera2D = get_node("../../Camera2D")
|
||||
@onready var health_caption: Label = $EncounterUI/HealthCaption
|
||||
@onready var status_label: Label = $EncounterUI/StatusLabel
|
||||
@onready var hint_label: Label = $EncounterUI/HintLabel
|
||||
@onready var health_bar: ProgressBar = $EncounterUI/HealthBar
|
||||
@onready var indicator: Control = $EncounterUI/BossIndicator
|
||||
@onready var indicator_label: Label = $EncounterUI/IndicatorLabel
|
||||
@onready var game_state: Node = get_node("/root/GameState")
|
||||
|
||||
const REVEAL_SECONDS: float = 1.15
|
||||
const REVEAL_SHAKE: float = 28.0
|
||||
const APPROACH_STANDOFF: float = 850.0
|
||||
|
||||
var reveal_started: bool = false
|
||||
var reveal_left: float = 0.0
|
||||
var encounter_active: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# One persistent objective lets boss victory flow through the same conquest
|
||||
|
|
@ -18,6 +31,10 @@ func _ready() -> void:
|
|||
boss.suppress_as_defeated()
|
||||
health_bar.max_value = boss.max_health()
|
||||
health_bar.value = 0
|
||||
health_caption.visible = false
|
||||
health_bar.visible = false
|
||||
indicator.visible = false
|
||||
indicator_label.visible = false
|
||||
status_label.text = "THE WARDEN IS DEAD"
|
||||
hint_label.text = ""
|
||||
return
|
||||
|
|
@ -26,15 +43,108 @@ func _ready() -> void:
|
|||
boss.health_changed.connect(_on_boss_health_changed)
|
||||
health_bar.max_value = boss.max_health()
|
||||
health_bar.value = boss.current_health()
|
||||
boss.set_combat_active(false)
|
||||
_set_combat_ui_visible(false)
|
||||
indicator.visible = true
|
||||
indicator_label.visible = true
|
||||
game_state.encounter_locks_movement = true
|
||||
# The encounter introduces itself in the world: the fleet is drawn toward
|
||||
# the signal until the Warden reaches the edge of view, then control is
|
||||
# returned after the roar. No dialog or separate scene hides the approach.
|
||||
_begin_approach.call_deferred()
|
||||
_refresh_status()
|
||||
|
||||
|
||||
func _begin_approach() -> void:
|
||||
# PlanetSurfaceController composes this encounter before it spawns the
|
||||
# restored fleet. Waiting one call guarantees there are ships for the
|
||||
# scripted move to steer.
|
||||
if not reveal_started and not fleet.ships.is_empty():
|
||||
fleet.move_to(boss.global_position + Vector2(0.0, APPROACH_STANDOFF))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if game_state != null:
|
||||
game_state.encounter_locks_movement = false
|
||||
if camera != null:
|
||||
camera.offset = Vector2.ZERO
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if boss.phase == boss.Phase.DEFEATED:
|
||||
return
|
||||
if not reveal_started:
|
||||
_update_indicator()
|
||||
if _boss_reaches_view():
|
||||
begin_reveal()
|
||||
return
|
||||
if encounter_active:
|
||||
return
|
||||
reveal_left -= delta
|
||||
var strength: float = REVEAL_SHAKE * clampf(reveal_left / REVEAL_SECONDS, 0.0, 1.0)
|
||||
camera.offset = Vector2(randf_range(-strength, strength), randf_range(-strength, strength))
|
||||
if reveal_left <= 0.0:
|
||||
camera.offset = Vector2.ZERO
|
||||
encounter_active = true
|
||||
boss.set_combat_active(true)
|
||||
game_state.encounter_locks_movement = false
|
||||
|
||||
|
||||
func begin_reveal() -> void:
|
||||
if reveal_started or boss.phase == boss.Phase.DEFEATED:
|
||||
return
|
||||
reveal_started = true
|
||||
reveal_left = REVEAL_SECONDS
|
||||
fleet.stop()
|
||||
indicator.visible = false
|
||||
indicator_label.visible = false
|
||||
_set_combat_ui_visible(true)
|
||||
status_label.text = "THE WARDEN AWAKENS"
|
||||
hint_label.text = ""
|
||||
Sfx.play(get_tree(), Sfx.BOSS_SCREAM)
|
||||
|
||||
|
||||
func _set_combat_ui_visible(shown: bool) -> void:
|
||||
for control in [health_caption, health_bar, status_label, hint_label]:
|
||||
control.visible = shown
|
||||
|
||||
|
||||
func _boss_reaches_view() -> bool:
|
||||
# World-space math is immediate. The viewport canvas transform trails a
|
||||
# Camera2D assignment until the render server updates, which made a fast
|
||||
# approach able to cross the threshold one frame before the detector saw
|
||||
# it. Including the tentacle reach makes the reveal begin when the first
|
||||
# part of the creature actually touches the view.
|
||||
var half_view: Vector2 = get_viewport_rect().size * 0.5 / camera.zoom
|
||||
var extent: float = boss.TENTACLE_LENGTH * boss.scale.x
|
||||
var separation: Vector2 = (boss.global_position - camera.global_position).abs()
|
||||
return separation.x <= half_view.x + extent \
|
||||
and separation.y <= half_view.y + extent
|
||||
|
||||
|
||||
func _update_indicator() -> void:
|
||||
var viewport_size: Vector2 = get_viewport_rect().size
|
||||
var screen_at: Vector2 = get_viewport().get_canvas_transform() * boss.global_position
|
||||
var from_center: Vector2 = screen_at - viewport_size * 0.5
|
||||
if from_center == Vector2.ZERO:
|
||||
from_center = Vector2.UP
|
||||
var half: Vector2 = indicator.size * 0.5
|
||||
indicator.position = Vector2(
|
||||
clampf(screen_at.x - half.x, 24.0, viewport_size.x - indicator.size.x - 24.0),
|
||||
clampf(screen_at.y - half.y, 170.0, viewport_size.y - indicator.size.y - 24.0))
|
||||
indicator.rotation = from_center.angle() + PI / 2.0
|
||||
indicator_label.position.x = clampf(indicator.position.x - 90.0,
|
||||
24.0, viewport_size.x - indicator_label.size.x - 24.0)
|
||||
indicator_label.position.y = indicator.position.y + indicator.size.y + 8.0
|
||||
|
||||
|
||||
func _refresh_status() -> void:
|
||||
var phase_name: String = "PHASE II · LASER STORM" \
|
||||
if boss.phase == boss.Phase.LASER_STORM else "PHASE I · HATCHLINGS"
|
||||
var phase_name: String = "PHASE III · EXPOSED CORE" if boss.phase == boss.Phase.CORE_EXPOSED \
|
||||
else ("PHASE II · LASER STORM" if boss.phase == boss.Phase.LASER_STORM \
|
||||
else "PHASE I · HATCHLINGS")
|
||||
status_label.text = "THE WARDEN · %s · EYES %d/%d" % [
|
||||
phase_name, boss.eyes_destroyed, boss.EYE_COUNT]
|
||||
hint_label.text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
|
||||
hint_label.text = "GOLDEN HATCHLINGS APPEAR EVERY THIRD WAVE · BREAK THE EYES, THEN THE CORE"
|
||||
|
||||
|
||||
func _on_boss_phase_changed(_phase: int) -> void:
|
||||
|
|
@ -49,6 +159,8 @@ func _on_boss_health_changed(current: int, maximum: int) -> void:
|
|||
|
||||
|
||||
func _on_boss_defeated() -> void:
|
||||
game_state.encounter_locks_movement = false
|
||||
camera.offset = Vector2.ZERO
|
||||
game_state.mark_point_captured(game_state.planet_id, 0)
|
||||
health_bar.value = 0
|
||||
status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
[gd_scene load_steps=5 format=3]
|
||||
[gd_scene load_steps=6 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://boss/BossEncounter.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://boss/OctopusBoss.tscn" id="2"]
|
||||
[ext_resource type="Script" path="res://ui/IconButton.gd" id="icon_button"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_health_background"]
|
||||
bg_color = Color(0.08, 0.005, 0.055, 0.92)
|
||||
|
|
@ -35,9 +36,9 @@ mouse_filter = 2
|
|||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -520.0
|
||||
offset_top = 280.0
|
||||
offset_top = 166.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 310.0
|
||||
offset_bottom = 196.0
|
||||
theme_override_colors/font_color = Color(1, 0.55, 0.76, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
|
|
@ -50,9 +51,9 @@ mouse_filter = 2
|
|||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -520.0
|
||||
offset_top = 312.0
|
||||
offset_top = 198.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 344.0
|
||||
offset_bottom = 230.0
|
||||
theme_override_styles/background = SubResource("StyleBox_health_background")
|
||||
theme_override_styles/fill = SubResource("StyleBox_health_fill")
|
||||
max_value = 18.0
|
||||
|
|
@ -61,10 +62,12 @@ show_percentage = false
|
|||
|
||||
[node name="StatusLabel" type="Label" parent="EncounterUI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 20.0
|
||||
offset_top = 354.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 396.0
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -600.0
|
||||
offset_top = 240.0
|
||||
offset_right = 600.0
|
||||
offset_bottom = 282.0
|
||||
theme_override_colors/font_color = Color(1, 0.45, 0.72, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
|
|
@ -74,13 +77,35 @@ horizontal_alignment = 1
|
|||
|
||||
[node name="HintLabel" type="Label" parent="EncounterUI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 20.0
|
||||
offset_top = 396.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = 436.0
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -600.0
|
||||
offset_top = 282.0
|
||||
offset_right = 600.0
|
||||
offset_bottom = 322.0
|
||||
theme_override_colors/font_color = Color(0.45, 0.9, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
|
||||
theme_override_constants/outline_size = 3
|
||||
theme_override_font_sizes/font_size = 17
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="BossIndicator" type="Control" parent="EncounterUI"]
|
||||
mouse_filter = 2
|
||||
offset_right = 112.0
|
||||
offset_bottom = 112.0
|
||||
script = ExtResource("icon_button")
|
||||
icon = 4
|
||||
glow_color = Color(1, 0.32, 0.66, 1)
|
||||
round_frame = true
|
||||
|
||||
[node name="IndicatorLabel" type="Label" parent="EncounterUI"]
|
||||
mouse_filter = 2
|
||||
offset_right = 292.0
|
||||
offset_bottom = 42.0
|
||||
theme_override_colors/font_color = Color(1, 0.45, 0.72, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "HOSTILE SIGNAL"
|
||||
horizontal_alignment = 1
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ signal health_changed(current: int, maximum: int)
|
|||
|
||||
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||
|
||||
@export var max_hp: int = 3
|
||||
@export var max_hp: int = 5
|
||||
|
||||
var hp: int = 3
|
||||
var hp: int = 5
|
||||
var opened: bool = false
|
||||
var phase: float = 0.0
|
||||
var redraw_left: float = 0.0
|
||||
|
|
|
|||
|
|
@ -9,9 +9,16 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
|||
@export var max_hp: int = 5
|
||||
@export var speed: float = 245.0
|
||||
@export var acceleration: float = 520.0
|
||||
@export var expulsion_speed: float = 520.0
|
||||
@export var dash_speed: float = 760.0
|
||||
|
||||
var boss: Node2D
|
||||
const REDRAW_INTERVAL: float = 1.0 / 12.0
|
||||
const EXPULSION_SECONDS: float = 0.85
|
||||
const BRAKE_SECONDS: float = 0.42
|
||||
const DASH_SECONDS: float = 0.48
|
||||
|
||||
enum Motion { EXPELLED, CHASING, BRAKING, DASHING }
|
||||
|
||||
var hp: int = 5
|
||||
var velocity: Vector2 = Vector2.ZERO
|
||||
|
|
@ -19,6 +26,11 @@ var phase: float = 0.0
|
|||
var dead: bool = false
|
||||
var redraw_left: float = 0.0
|
||||
var fleet: Node2D = null
|
||||
var motion: int = Motion.CHASING
|
||||
var motion_left: float = 0.0
|
||||
var dash_cooldown: float = 0.0
|
||||
var dash_direction: Vector2 = Vector2.ZERO
|
||||
var uses_dash_pattern: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -31,25 +43,71 @@ func _ready() -> void:
|
|||
queue_redraw()
|
||||
|
||||
|
||||
# The boss throws hatchlings clear of its now much larger silhouette before
|
||||
# they acquire the fleet. Every third hatchling also gets the stop-and-lunge
|
||||
# rhythm requested in #189; the others remain steady pursuers so a wave is
|
||||
# varied rather than uniformly twitchy.
|
||||
func expel(direction: Vector2, dashes: bool) -> void:
|
||||
var outward: Vector2 = direction.normalized()
|
||||
uses_dash_pattern = dashes
|
||||
motion = Motion.EXPELLED
|
||||
motion_left = EXPULSION_SECONDS * randf_range(0.8, 1.2)
|
||||
velocity = outward * expulsion_speed * randf_range(0.85, 1.15)
|
||||
dash_cooldown = randf_range(1.2, 2.8)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
phase += delta
|
||||
if not is_instance_valid(fleet):
|
||||
fleet = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet != null and not fleet.ships.is_empty():
|
||||
var to_fleet: Vector2 = fleet.get_centroid() - global_position
|
||||
if to_fleet.length() > 1.0:
|
||||
velocity = velocity.move_toward(to_fleet.normalized() * speed,
|
||||
acceleration * delta)
|
||||
global_position += velocity * delta
|
||||
if velocity.length() > 4.0:
|
||||
rotation = lerp_angle(rotation, velocity.angle() + PI / 2.0,
|
||||
7.0 * delta)
|
||||
_update_motion(delta)
|
||||
global_position += velocity * delta
|
||||
if velocity.length() > 4.0:
|
||||
rotation = lerp_angle(rotation, velocity.angle() + PI / 2.0,
|
||||
7.0 * delta)
|
||||
redraw_left -= delta
|
||||
if redraw_left <= 0.0:
|
||||
redraw_left = REDRAW_INTERVAL
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _update_motion(delta: float) -> void:
|
||||
if motion == Motion.EXPELLED:
|
||||
motion_left -= delta
|
||||
if motion_left <= 0.0:
|
||||
motion = Motion.CHASING
|
||||
return
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
velocity = velocity.move_toward(Vector2.ZERO, acceleration * delta)
|
||||
return
|
||||
var to_fleet: Vector2 = fleet.get_centroid() - global_position
|
||||
if motion == Motion.BRAKING:
|
||||
motion_left -= delta
|
||||
velocity = velocity.move_toward(Vector2.ZERO, acceleration * 2.2 * delta)
|
||||
if motion_left <= 0.0:
|
||||
motion = Motion.DASHING
|
||||
motion_left = DASH_SECONDS
|
||||
dash_direction = to_fleet.normalized().rotated(randf_range(-0.42, 0.42))
|
||||
return
|
||||
if motion == Motion.DASHING:
|
||||
motion_left -= delta
|
||||
velocity = velocity.move_toward(dash_direction * dash_speed,
|
||||
acceleration * 4.0 * delta)
|
||||
if motion_left <= 0.0:
|
||||
motion = Motion.CHASING
|
||||
dash_cooldown = randf_range(1.8, 3.8)
|
||||
return
|
||||
if uses_dash_pattern:
|
||||
dash_cooldown -= delta
|
||||
if dash_cooldown <= 0.0:
|
||||
motion = Motion.BRAKING
|
||||
motion_left = BRAKE_SECONDS
|
||||
return
|
||||
if to_fleet.length() > 1.0:
|
||||
velocity = velocity.move_toward(to_fleet.normalized() * speed,
|
||||
acceleration * delta)
|
||||
|
||||
|
||||
func take_damage(amount: int) -> bool:
|
||||
if dead:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -9,19 +9,22 @@ const EYE_SCENE: PackedScene = preload("res://boss/BossEye.tscn")
|
|||
const MINION_SCENE: PackedScene = preload("res://boss/BossMinion.tscn")
|
||||
const RETURN_SCENE: PackedScene = preload("res://boss/BossReturnSignal.tscn")
|
||||
const LASER_SCENE: PackedScene = preload("res://enemies/EnemyBigLaserBeam.tscn")
|
||||
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||
|
||||
enum Phase { MINION_WAVES, LASER_STORM, DEFEATED }
|
||||
enum Phase { MINION_WAVES, LASER_STORM, CORE_EXPOSED, DEFEATED }
|
||||
|
||||
const EYE_COUNT: int = 6
|
||||
const PHASE_TWO_EYES: int = 3
|
||||
const BODY_RADIUS: float = 86.0
|
||||
const TENTACLE_LENGTH: float = 235.0
|
||||
const MAX_ACTIVE_MINIONS: int = 8
|
||||
const MAX_ACTIVE_MINIONS: int = 16
|
||||
const GOLDEN_WAVE_PERIOD: int = 3
|
||||
const CORE_MAX_HEALTH: int = 36
|
||||
const REDRAW_INTERVAL: float = 1.0 / 12.0
|
||||
|
||||
@export var first_wave_delay: float = 1.5
|
||||
@export var wave_interval: float = 6.0
|
||||
@export var minions_per_wave: int = 5
|
||||
@export var minions_per_wave: int = 7
|
||||
@export var laser_interval: float = 3.5
|
||||
@export var laser_charge_time: float = 0.85
|
||||
|
||||
|
|
@ -36,7 +39,10 @@ var spawned_minions: Array = []
|
|||
var return_signals: Array = []
|
||||
var animation_phase: float = 0.0
|
||||
var redraw_left: float = 0.0
|
||||
var total_max_health: int = 0
|
||||
var total_max_health: int = CORE_MAX_HEALTH
|
||||
var core_health: int = CORE_MAX_HEALTH
|
||||
var core_exposed: bool = false
|
||||
var combat_active: bool = true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -68,7 +74,7 @@ func eye_position(index: int) -> Vector2:
|
|||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if phase == Phase.DEFEATED:
|
||||
if phase == Phase.DEFEATED or not combat_active:
|
||||
return
|
||||
animation_phase += delta
|
||||
|
||||
|
|
@ -89,16 +95,25 @@ func _process(delta: float) -> void:
|
|||
|
||||
|
||||
func spawn_wave() -> void:
|
||||
if not combat_active or phase == Phase.CORE_EXPOSED or phase == Phase.DEFEATED:
|
||||
return
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty() or get_parent() == null:
|
||||
return
|
||||
_prune_invalid(spawned_minions)
|
||||
var wanted: int = maxi(3,
|
||||
var wanted: int = maxi(5,
|
||||
minions_per_wave - (2 if phase == Phase.LASER_STORM else 0))
|
||||
var count: int = mini(wanted, MAX_ACTIVE_MINIONS - spawned_minions.size())
|
||||
if count <= 0:
|
||||
return
|
||||
var marked_slot: int = wave_index % count
|
||||
# One useful return every third ACTUAL wave. The first contains one so the
|
||||
# mechanic teaches itself immediately; the two red-only waves afterwards
|
||||
# make the gold target an event rather than permanent scenery.
|
||||
var marked_slot: int = wave_index % GOLDEN_WAVE_PERIOD
|
||||
if marked_slot != 0:
|
||||
marked_slot = -1
|
||||
else:
|
||||
marked_slot = wave_index % count
|
||||
for i in range(count):
|
||||
var minion: Area2D = MINION_SCENE.instantiate()
|
||||
minion.marked = i == marked_slot
|
||||
|
|
@ -108,8 +123,10 @@ func spawn_wave() -> void:
|
|||
var angle: float = TAU * float(i) / float(count) + float(wave_index) * 0.47
|
||||
# Minions are siblings rather than boss children, so account for the
|
||||
# encounter scene's imposing boss scale when placing them off its body.
|
||||
var launch_radius: float = BODY_RADIUS * scale.x + 34.0
|
||||
minion.global_position = global_position + Vector2.from_angle(angle) * launch_radius
|
||||
var launch_direction := Vector2.from_angle(angle)
|
||||
var launch_radius: float = BODY_RADIUS * scale.x + 60.0
|
||||
minion.global_position = global_position + launch_direction * launch_radius
|
||||
minion.expel(launch_direction, (i + wave_index) % 3 == 0)
|
||||
spawned_minions.append(minion)
|
||||
wave_index += 1
|
||||
|
||||
|
|
@ -140,7 +157,7 @@ func _on_eye_destroyed(_index: int) -> void:
|
|||
eyes_destroyed += 1
|
||||
health_changed.emit(current_health(), max_health())
|
||||
if eyes_destroyed >= EYE_COUNT:
|
||||
_defeat()
|
||||
_expose_core()
|
||||
return
|
||||
if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES:
|
||||
phase = Phase.LASER_STORM
|
||||
|
|
@ -154,7 +171,7 @@ func _on_eye_health_changed(_current: int, _maximum: int) -> void:
|
|||
|
||||
|
||||
func current_health() -> int:
|
||||
var total: int = 0
|
||||
var total: int = core_health
|
||||
for eye in eyes:
|
||||
if is_instance_valid(eye):
|
||||
total += maxi(int(eye.hp), 0)
|
||||
|
|
@ -187,6 +204,36 @@ func fire_laser_volley() -> void:
|
|||
beam.launch(global_position, toward.rotated(float(spread)))
|
||||
|
||||
|
||||
func set_combat_active(active: bool) -> void:
|
||||
combat_active = active
|
||||
if active:
|
||||
wave_timer = first_wave_delay
|
||||
laser_timer = laser_interval
|
||||
|
||||
|
||||
func _expose_core() -> void:
|
||||
if core_exposed or phase == Phase.DEFEATED:
|
||||
return
|
||||
core_exposed = true
|
||||
phase = Phase.CORE_EXPOSED
|
||||
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
|
||||
$CoreCollision.set_deferred("disabled", false)
|
||||
phase_changed.emit(phase)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func take_damage(amount: int) -> bool:
|
||||
if not core_exposed or phase == Phase.DEFEATED:
|
||||
return false
|
||||
core_health = maxi(core_health - maxi(amount, 1), 0)
|
||||
health_changed.emit(current_health(), max_health())
|
||||
if core_health > 0:
|
||||
queue_redraw()
|
||||
return false
|
||||
_defeat()
|
||||
return true
|
||||
|
||||
|
||||
func _forget_minion(minion: Node) -> void:
|
||||
spawned_minions.erase(minion)
|
||||
|
||||
|
|
@ -205,6 +252,9 @@ func _defeat() -> void:
|
|||
if phase == Phase.DEFEATED:
|
||||
return
|
||||
phase = Phase.DEFEATED
|
||||
core_health = 0
|
||||
remove_from_group(COMBAT_TARGET.GROUP)
|
||||
$CoreCollision.set_deferred("disabled", true)
|
||||
for minion in spawned_minions:
|
||||
if is_instance_valid(minion):
|
||||
minion.retire()
|
||||
|
|
@ -220,6 +270,8 @@ func _defeat() -> void:
|
|||
|
||||
func suppress_as_defeated() -> void:
|
||||
phase = Phase.DEFEATED
|
||||
core_health = 0
|
||||
combat_active = false
|
||||
visible = false
|
||||
set_process(false)
|
||||
|
||||
|
|
@ -251,8 +303,10 @@ func _draw() -> void:
|
|||
# The central eye watches but is armoured; only tentacle eyes opened by a
|
||||
# returning marked minion join the combat-target group.
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2(0.72, 1.0))
|
||||
var core_color := Color(1.0, 0.78, 0.18) if core_exposed \
|
||||
else Color(0.28, 0.92, 1.0)
|
||||
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 31.0,
|
||||
Color(0.28, 0.92, 1.0), 0.8 + 0.2 * sin(animation_phase * 3.0), dark)
|
||||
core_color, 0.8 + 0.2 * sin(animation_phase * 3.0), dark)
|
||||
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 12.0,
|
||||
Color(1.0, 0.82, 0.2), 1.0, dark)
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://boss/OctopusBoss.gd" id="1"]
|
||||
|
||||
[node name="Boss" type="Node2D"]
|
||||
position = Vector2(0, -560)
|
||||
scale = Vector2(1.35, 1.35)
|
||||
[sub_resource type="CircleShape2D" id="CircleShape_core"]
|
||||
radius = 76.0
|
||||
|
||||
[node name="Boss" type="Area2D"]
|
||||
position = Vector2(0, -3000)
|
||||
scale = Vector2(2.7, 2.7)
|
||||
z_index = 2
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="CoreCollision" type="CollisionShape2D" parent="."]
|
||||
disabled = true
|
||||
shape = SubResource("CircleShape_core")
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ func _process(delta: float) -> void:
|
|||
# #124: the breach drives. One check here covers the stick, the keyboard
|
||||
# and the released-again path together - three separate gates would be
|
||||
# three chances for one of them to keep steering.
|
||||
if GameState.intro_locks_movement:
|
||||
if GameState.intro_locks_movement or GameState.encounter_locks_movement:
|
||||
keyboard_steering = false
|
||||
else:
|
||||
_apply_steering()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ var catalog_faults: PackedStringArray = PackedStringArray()
|
|||
# set, so the fleet goes where the script sends it rather than fighting the
|
||||
# player for the last few seconds of the prologue.
|
||||
var intro_locks_movement: bool = false
|
||||
# Short in-world reveals (currently the boss arrival) use their own lock so
|
||||
# they do not pretend the prologue is active merely to steer the fleet for a
|
||||
# few seconds.
|
||||
var encounter_locks_movement: bool = false
|
||||
# #123: the prologue's convoy is bigger than any fleet the player can field
|
||||
# yet - it is not their fleet, it is the one they are about to lose. An
|
||||
# explicit override rather than a fake doctrine, so nothing about the
|
||||
|
|
@ -751,6 +755,7 @@ func reset_progression() -> void:
|
|||
func reset_new_game() -> void:
|
||||
intro_active = true
|
||||
intro_locks_movement = false
|
||||
encounter_locks_movement = false
|
||||
intro_fleet_size = 0
|
||||
has_return_position = false
|
||||
surviving_ship_count = STARTING_SHIP_COUNT
|
||||
|
|
|
|||
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