Expand and stage boss encounter

This commit is contained in:
Loïc Wisniewski 2026-09-03 22:00:26 +02:00
commit 66b314f51a
17 changed files with 485 additions and 64 deletions

View file

@ -27,6 +27,7 @@ const SHIP_DEATH: String = "ship_death"
const ENEMY_DEATH: String = "enemy_death" const ENEMY_DEATH: String = "enemy_death"
const BUILDING_DEATH: String = "building_death" const BUILDING_DEATH: String = "building_death"
const DIALOG: String = "dialog" const DIALOG: String = "dialog"
const BOSS_SCREAM: String = "boss_scream"
const SOUNDS: Dictionary = { const SOUNDS: Dictionary = {
SHOT: "res://audio/sfx_shot.wav", SHOT: "res://audio/sfx_shot.wav",
@ -34,6 +35,7 @@ const SOUNDS: Dictionary = {
ENEMY_DEATH: "res://audio/sfx_ship_death.wav", ENEMY_DEATH: "res://audio/sfx_ship_death.wav",
BUILDING_DEATH: "res://audio/sfx_building_death.wav", BUILDING_DEATH: "res://audio/sfx_building_death.wav",
DIALOG: "res://audio/sfx_dialog.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 # 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, ENEMY_DEATH: -23.0,
BUILDING_DEATH: -23.0, BUILDING_DEATH: -23.0,
DIALOG: -19.0, DIALOG: -19.0,
BOSS_SCREAM: -13.0,
} }
# Shots vary a little in pitch so a stream of them does not turn into one # Shots vary a little in pitch so a stream of them does not turn into one

View file

@ -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)] 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__": if __name__ == "__main__":
_write("sfx_shot", shot()) _write("sfx_shot", shot())
_write("sfx_dialog", dialog()) _write("sfx_dialog", dialog())
_write("sfx_boss_scream", boss_scream())
# A vessel coming apart: bright, fast, with real low end under it. # 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)) _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. # A structure: longer, deeper, and it keeps coming down afterwards.

BIN
audio/sfx_boss_scream.wav Normal file

Binary file not shown.

View 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

View file

@ -2,11 +2,24 @@ class_name BossEncounter
extends Node2D extends Node2D
@onready var boss: Node2D = $Boss @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 status_label: Label = $EncounterUI/StatusLabel
@onready var hint_label: Label = $EncounterUI/HintLabel @onready var hint_label: Label = $EncounterUI/HintLabel
@onready var health_bar: ProgressBar = $EncounterUI/HealthBar @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") @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: func _ready() -> void:
# One persistent objective lets boss victory flow through the same conquest # One persistent objective lets boss victory flow through the same conquest
@ -18,6 +31,10 @@ func _ready() -> void:
boss.suppress_as_defeated() boss.suppress_as_defeated()
health_bar.max_value = boss.max_health() health_bar.max_value = boss.max_health()
health_bar.value = 0 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" status_label.text = "THE WARDEN IS DEAD"
hint_label.text = "" hint_label.text = ""
return return
@ -26,15 +43,108 @@ func _ready() -> void:
boss.health_changed.connect(_on_boss_health_changed) boss.health_changed.connect(_on_boss_health_changed)
health_bar.max_value = boss.max_health() health_bar.max_value = boss.max_health()
health_bar.value = boss.current_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() _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: func _refresh_status() -> void:
var phase_name: String = "PHASE II · LASER STORM" \ var phase_name: String = "PHASE III · EXPOSED CORE" if boss.phase == boss.Phase.CORE_EXPOSED \
if boss.phase == boss.Phase.LASER_STORM else "PHASE I · HATCHLINGS" 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" % [ status_label.text = "THE WARDEN · %s · EYES %d/%d" % [
phase_name, boss.eyes_destroyed, boss.EYE_COUNT] 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: 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: 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) game_state.mark_point_captured(game_state.planet_id, 0)
health_bar.value = 0 health_bar.value = 0
status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED" status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED"

View file

@ -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="Script" path="res://boss/BossEncounter.gd" id="1"]
[ext_resource type="PackedScene" path="res://boss/OctopusBoss.tscn" id="2"] [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"] [sub_resource type="StyleBoxFlat" id="StyleBox_health_background"]
bg_color = Color(0.08, 0.005, 0.055, 0.92) bg_color = Color(0.08, 0.005, 0.055, 0.92)
@ -35,9 +36,9 @@ mouse_filter = 2
anchor_left = 0.5 anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
offset_left = -520.0 offset_left = -520.0
offset_top = 280.0 offset_top = 166.0
offset_right = 520.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_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_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
theme_override_constants/outline_size = 4 theme_override_constants/outline_size = 4
@ -50,9 +51,9 @@ mouse_filter = 2
anchor_left = 0.5 anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
offset_left = -520.0 offset_left = -520.0
offset_top = 312.0 offset_top = 198.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 344.0 offset_bottom = 230.0
theme_override_styles/background = SubResource("StyleBox_health_background") theme_override_styles/background = SubResource("StyleBox_health_background")
theme_override_styles/fill = SubResource("StyleBox_health_fill") theme_override_styles/fill = SubResource("StyleBox_health_fill")
max_value = 18.0 max_value = 18.0
@ -61,10 +62,12 @@ show_percentage = false
[node name="StatusLabel" type="Label" parent="EncounterUI"] [node name="StatusLabel" type="Label" parent="EncounterUI"]
mouse_filter = 2 mouse_filter = 2
offset_left = 20.0 anchor_left = 0.5
offset_top = 354.0 anchor_right = 0.5
offset_right = 700.0 offset_left = -600.0
offset_bottom = 396.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_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_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
theme_override_constants/outline_size = 4 theme_override_constants/outline_size = 4
@ -74,13 +77,35 @@ horizontal_alignment = 1
[node name="HintLabel" type="Label" parent="EncounterUI"] [node name="HintLabel" type="Label" parent="EncounterUI"]
mouse_filter = 2 mouse_filter = 2
offset_left = 20.0 anchor_left = 0.5
offset_top = 396.0 anchor_right = 0.5
offset_right = 700.0 offset_left = -600.0
offset_bottom = 436.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_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_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
theme_override_constants/outline_size = 3 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" text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
horizontal_alignment = 1 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

View file

@ -6,9 +6,9 @@ signal health_changed(current: int, maximum: int)
const COMBAT_TARGET = preload("res://core/CombatTarget.gd") 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 opened: bool = false
var phase: float = 0.0 var phase: float = 0.0
var redraw_left: float = 0.0 var redraw_left: float = 0.0

View file

@ -9,9 +9,16 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
@export var max_hp: int = 5 @export var max_hp: int = 5
@export var speed: float = 245.0 @export var speed: float = 245.0
@export var acceleration: float = 520.0 @export var acceleration: float = 520.0
@export var expulsion_speed: float = 520.0
@export var dash_speed: float = 760.0
var boss: Node2D var boss: Node2D
const REDRAW_INTERVAL: float = 1.0 / 12.0 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 hp: int = 5
var velocity: Vector2 = Vector2.ZERO var velocity: Vector2 = Vector2.ZERO
@ -19,6 +26,11 @@ var phase: float = 0.0
var dead: bool = false var dead: bool = false
var redraw_left: float = 0.0 var redraw_left: float = 0.0
var fleet: Node2D = null 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: func _ready() -> void:
@ -31,25 +43,71 @@ func _ready() -> void:
queue_redraw() 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: func _physics_process(delta: float) -> void:
phase += delta phase += delta
if not is_instance_valid(fleet): if not is_instance_valid(fleet):
fleet = get_tree().get_first_node_in_group("player_fleet") fleet = get_tree().get_first_node_in_group("player_fleet")
if fleet != null and not fleet.ships.is_empty(): _update_motion(delta)
var to_fleet: Vector2 = fleet.get_centroid() - global_position global_position += velocity * delta
if to_fleet.length() > 1.0: if velocity.length() > 4.0:
velocity = velocity.move_toward(to_fleet.normalized() * speed, rotation = lerp_angle(rotation, velocity.angle() + PI / 2.0,
acceleration * delta) 7.0 * 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 redraw_left -= delta
if redraw_left <= 0.0: if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL redraw_left = REDRAW_INTERVAL
queue_redraw() 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: func take_damage(amount: int) -> bool:
if dead: if dead:
return false return false

View file

@ -9,19 +9,22 @@ const EYE_SCENE: PackedScene = preload("res://boss/BossEye.tscn")
const MINION_SCENE: PackedScene = preload("res://boss/BossMinion.tscn") const MINION_SCENE: PackedScene = preload("res://boss/BossMinion.tscn")
const RETURN_SCENE: PackedScene = preload("res://boss/BossReturnSignal.tscn") const RETURN_SCENE: PackedScene = preload("res://boss/BossReturnSignal.tscn")
const LASER_SCENE: PackedScene = preload("res://enemies/EnemyBigLaserBeam.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 EYE_COUNT: int = 6
const PHASE_TWO_EYES: int = 3 const PHASE_TWO_EYES: int = 3
const BODY_RADIUS: float = 86.0 const BODY_RADIUS: float = 86.0
const TENTACLE_LENGTH: float = 235.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 const REDRAW_INTERVAL: float = 1.0 / 12.0
@export var first_wave_delay: float = 1.5 @export var first_wave_delay: float = 1.5
@export var wave_interval: float = 6.0 @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_interval: float = 3.5
@export var laser_charge_time: float = 0.85 @export var laser_charge_time: float = 0.85
@ -36,7 +39,10 @@ var spawned_minions: Array = []
var return_signals: Array = [] var return_signals: Array = []
var animation_phase: float = 0.0 var animation_phase: float = 0.0
var redraw_left: 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: func _ready() -> void:
@ -68,7 +74,7 @@ func eye_position(index: int) -> Vector2:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if phase == Phase.DEFEATED: if phase == Phase.DEFEATED or not combat_active:
return return
animation_phase += delta animation_phase += delta
@ -89,16 +95,25 @@ func _process(delta: float) -> void:
func spawn_wave() -> 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") var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty() or get_parent() == null: if fleet == null or fleet.ships.is_empty() or get_parent() == null:
return return
_prune_invalid(spawned_minions) _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)) minions_per_wave - (2 if phase == Phase.LASER_STORM else 0))
var count: int = mini(wanted, MAX_ACTIVE_MINIONS - spawned_minions.size()) var count: int = mini(wanted, MAX_ACTIVE_MINIONS - spawned_minions.size())
if count <= 0: if count <= 0:
return 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): for i in range(count):
var minion: Area2D = MINION_SCENE.instantiate() var minion: Area2D = MINION_SCENE.instantiate()
minion.marked = i == marked_slot 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 var angle: float = TAU * float(i) / float(count) + float(wave_index) * 0.47
# Minions are siblings rather than boss children, so account for the # Minions are siblings rather than boss children, so account for the
# encounter scene's imposing boss scale when placing them off its body. # encounter scene's imposing boss scale when placing them off its body.
var launch_radius: float = BODY_RADIUS * scale.x + 34.0 var launch_direction := Vector2.from_angle(angle)
minion.global_position = global_position + Vector2.from_angle(angle) * launch_radius 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) spawned_minions.append(minion)
wave_index += 1 wave_index += 1
@ -140,7 +157,7 @@ func _on_eye_destroyed(_index: int) -> void:
eyes_destroyed += 1 eyes_destroyed += 1
health_changed.emit(current_health(), max_health()) health_changed.emit(current_health(), max_health())
if eyes_destroyed >= EYE_COUNT: if eyes_destroyed >= EYE_COUNT:
_defeat() _expose_core()
return return
if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES: if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES:
phase = Phase.LASER_STORM phase = Phase.LASER_STORM
@ -154,7 +171,7 @@ func _on_eye_health_changed(_current: int, _maximum: int) -> void:
func current_health() -> int: func current_health() -> int:
var total: int = 0 var total: int = core_health
for eye in eyes: for eye in eyes:
if is_instance_valid(eye): if is_instance_valid(eye):
total += maxi(int(eye.hp), 0) total += maxi(int(eye.hp), 0)
@ -187,6 +204,36 @@ func fire_laser_volley() -> void:
beam.launch(global_position, toward.rotated(float(spread))) 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: func _forget_minion(minion: Node) -> void:
spawned_minions.erase(minion) spawned_minions.erase(minion)
@ -205,6 +252,9 @@ func _defeat() -> void:
if phase == Phase.DEFEATED: if phase == Phase.DEFEATED:
return return
phase = Phase.DEFEATED phase = Phase.DEFEATED
core_health = 0
remove_from_group(COMBAT_TARGET.GROUP)
$CoreCollision.set_deferred("disabled", true)
for minion in spawned_minions: for minion in spawned_minions:
if is_instance_valid(minion): if is_instance_valid(minion):
minion.retire() minion.retire()
@ -220,6 +270,8 @@ func _defeat() -> void:
func suppress_as_defeated() -> void: func suppress_as_defeated() -> void:
phase = Phase.DEFEATED phase = Phase.DEFEATED
core_health = 0
combat_active = false
visible = false visible = false
set_process(false) set_process(false)
@ -251,8 +303,10 @@ func _draw() -> void:
# The central eye watches but is armoured; only tentacle eyes opened by a # The central eye watches but is armoured; only tentacle eyes opened by a
# returning marked minion join the combat-target group. # returning marked minion join the combat-target group.
draw_set_transform(Vector2.ZERO, 0.0, Vector2(0.72, 1.0)) 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, 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, NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 12.0,
Color(1.0, 0.82, 0.2), 1.0, dark) Color(1.0, 0.82, 0.2), 1.0, dark)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)

View file

@ -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"] [ext_resource type="Script" path="res://boss/OctopusBoss.gd" id="1"]
[node name="Boss" type="Node2D"] [sub_resource type="CircleShape2D" id="CircleShape_core"]
position = Vector2(0, -560) radius = 76.0
scale = Vector2(1.35, 1.35)
[node name="Boss" type="Area2D"]
position = Vector2(0, -3000)
scale = Vector2(2.7, 2.7)
z_index = 2 z_index = 2
script = ExtResource("1") script = ExtResource("1")
[node name="CoreCollision" type="CollisionShape2D" parent="."]
disabled = true
shape = SubResource("CircleShape_core")

View file

@ -298,7 +298,7 @@ func _process(delta: float) -> void:
# #124: the breach drives. One check here covers the stick, the keyboard # #124: the breach drives. One check here covers the stick, the keyboard
# and the released-again path together - three separate gates would be # and the released-again path together - three separate gates would be
# three chances for one of them to keep steering. # 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 keyboard_steering = false
else: else:
_apply_steering() _apply_steering()

View file

@ -47,6 +47,10 @@ var catalog_faults: PackedStringArray = PackedStringArray()
# set, so the fleet goes where the script sends it rather than fighting the # set, so the fleet goes where the script sends it rather than fighting the
# player for the last few seconds of the prologue. # player for the last few seconds of the prologue.
var intro_locks_movement: bool = false 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 # #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 # 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 # explicit override rather than a fake doctrine, so nothing about the
@ -751,6 +755,7 @@ func reset_progression() -> void:
func reset_new_game() -> void: func reset_new_game() -> void:
intro_active = true intro_active = true
intro_locks_movement = false intro_locks_movement = false
encounter_locks_movement = false
intro_fleet_size = 0 intro_fleet_size = 0
has_return_position = false has_return_position = false
surviving_ship_count = STARTING_SHIP_COUNT surviving_ship_count = STARTING_SHIP_COUNT

View 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.

View file

@ -0,0 +1 @@
uid://c50xlb8b4u84r

View 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")

View file

@ -33,6 +33,11 @@ func _process(_delta: float) -> void:
laid_out = true laid_out = true
var encounter: Node = surface.get_node("SurfaceFeatures/BossEncounter") var encounter: Node = surface.get_node("SurfaceFeatures/BossEncounter")
var boss: Node2D = encounter.get_node("Boss") 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.wave_timer = 999.0
boss.laser_timer = 999.0 boss.laser_timer = 999.0
for i in range(3): for i in range(3):
@ -44,6 +49,8 @@ func _process(_delta: float) -> void:
boss.open_next_eye() boss.open_next_eye()
boss.spawn_wave() boss.spawn_wave()
for minion in get_tree().get_nodes_in_group("boss_minions"): 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.speed = 0.0
minion.acceleration = 0.0 minion.acceleration = 0.0
boss.laser_charge_time = 999.0 boss.laser_charge_time = 999.0

View file

@ -8,12 +8,13 @@ extends SceneTree
# scripted profile is excluded from the map editor # scripted profile is excluded from the map editor
# B. landing composes BossEncounter instead of a PlanetSetup and does not # B. landing composes BossEncounter instead of a PlanetSetup and does not
# mark the world conquered for free # mark the world conquered for free
# C. phase one spawns an octopus wave with exactly one marked hatchling # C. the off-screen approach locks movement and reveals with its own cue
# D. killing the marked hatchling sends a visible return to the boss and # 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 # opens an attackable tentacle eye
# E. three destroyed eyes start the three-lane laser phase # F. three destroyed eyes start the three-lane laser phase
# F. destroying every eye defeats the boss, persists conquest, and a second # G. eyes expose a separate core; destroying it persists conquest, and a
# landing does not resurrect it # second landing does not resurrect the boss
const COMBAT_TARGET = preload("res://core/CombatTarget.gd") const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
const BOSS_SCRIPT = preload("res://boss/OctopusBoss.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: if surface.get_node("UI/CaptureLabel").visible:
return _fail("ordinary capture-point status is visible during the boss encounter") return _fail("ordinary capture-point status is visible during the boss encounter")
boss = encounter.get_node("Boss") 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.wave_timer = 999.0
boss.laser_timer = 999.0 boss.laser_timer = 999.0
var health_bar: ProgressBar = encounter.get_node("EncounterUI/HealthBar") 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): if eye.opened or eye.is_in_group(COMBAT_TARGET.GROUP):
return _fail("a tentacle eye starts open and attackable") return _fail("a tentacle eye starts open and attackable")
# --- C. phase-one wave ---------------------------------------- # --- D. larger, varied, one-in-three waves --------------------
boss.spawn_wave() boss.spawn_wave()
var minions: Array = get_nodes_in_group("boss_minions") var minions: Array = get_nodes_in_group("boss_minions")
if minions.size() != boss.minions_per_wave: if minions.size() != boss.minions_per_wave:
return _fail("phase one spawned %d hatchlings, expected %d" \ return _fail("phase one spawned %d hatchlings, expected %d" \
% [minions.size(), boss.minions_per_wave]) % [minions.size(), boss.minions_per_wave])
var marked: Node = null var marked: Node = null
var dashers: int = 0
for minion in minions: for minion in minions:
if minion.uses_dash_pattern:
dashers += 1
if minion.marked: if minion.marked:
if marked != null: if marked != null:
return _fail("one wave contains more than one marked hatchling") return _fail("one wave contains more than one marked hatchling")
@ -139,6 +174,25 @@ func _process(delta: float) -> bool:
minion.take_damage(999) minion.take_damage(999)
if marked == null: if marked == null:
return _fail("phase-one wave has no marked hatchling") 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: if marked.max_hp < 5:
return _fail("boss hatchlings still have only %d health" % marked.max_hp) return _fail("boss hatchlings still have only %d health" % marked.max_hp)
marked.take_damage(1) marked.take_damage(1)
@ -151,7 +205,20 @@ func _process(delta: float) -> bool:
if returns.size() != 1: if returns.size() != 1:
return _fail("marked death launched %d returns, expected one" % returns.size()) return _fail("marked death launched %d returns, expected one" % returns.size())
returns[0].speed = 4000.0 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 stage = 1
elapsed = 0.0 elapsed = 0.0
return false return false
@ -180,6 +247,8 @@ func _process(delta: float) -> bool:
eye.take_damage(999) eye.take_damage(999)
if boss.eyes_destroyed != 1: if boss.eyes_destroyed != 1:
return _fail("destroying the first eye did not damage the boss") 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") print("return opened a tentacle eye; the eye accepted damage and broke")
_open_return() _open_return()
stage = 2 stage = 2
@ -224,15 +293,6 @@ func _process(delta: float) -> bool:
return _fail("boss laser extends backwards through its source") return _fail("boss laser extends backwards through its source")
beam.queue_free() beam.queue_free()
boss.spawn_wave() 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()
boss.spawn_wave() boss.spawn_wave()
if boss.spawned_minions.size() > boss.MAX_ACTIVE_MINIONS: if boss.spawned_minions.size() > boss.MAX_ACTIVE_MINIONS:
@ -242,18 +302,29 @@ func _process(delta: float) -> bool:
minion.retire() minion.retire()
print("three broken eyes started a telegraphed three-lane laser phase") 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): for i in range(3, boss.EYE_COUNT):
boss.eyes[i].open_eye() boss.eyes[i].open_eye()
boss.eyes[i].take_damage(999) 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: 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): if not state.is_planet_conquered(state.planet_id):
return _fail("boss victory did not persist through planet conquest state") return _fail("boss victory did not persist through planet conquest state")
if encounter.health_bar.value != 0 \ if encounter.health_bar.value != 0 \
or encounter.health_bar.max_value != boss.max_health(): or encounter.health_bar.max_value != boss.max_health():
return _fail("boss health bar did not end empty with a stable total") 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.free()
surface = load("res://surface/PlanetSurface.tscn").instantiate() surface = load("res://surface/PlanetSurface.tscn").instantiate()