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

@ -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"

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="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

View file

@ -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

View file

@ -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

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

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"]
[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")