Tune and optimize boss encounter

This commit is contained in:
Loïc Wisniewski 2026-09-03 11:49:51 +02:00
commit 68985316e5
16 changed files with 316 additions and 88 deletions

View file

@ -4,6 +4,7 @@ extends Node2D
@onready var boss: Node2D = $Boss
@onready var status_label: Label = $EncounterUI/StatusLabel
@onready var hint_label: Label = $EncounterUI/HintLabel
@onready var health_bar: ProgressBar = $EncounterUI/HealthBar
@onready var game_state: Node = get_node("/root/GameState")
@ -15,15 +16,20 @@ func _ready() -> void:
game_state.planet_definition_id)
if game_state.is_planet_conquered(game_state.planet_id):
boss.suppress_as_defeated()
health_bar.max_value = boss.max_health()
health_bar.value = 0
status_label.text = "THE WARDEN IS DEAD"
hint_label.text = ""
return
boss.defeated.connect(_on_boss_defeated)
boss.phase_changed.connect(_on_boss_phase_changed)
boss.health_changed.connect(_on_boss_health_changed)
health_bar.max_value = boss.max_health()
health_bar.value = boss.current_health()
_refresh_status()
func _process(_delta: float) -> void:
if not is_instance_valid(boss) or boss.phase == boss.Phase.DEFEATED:
return
func _refresh_status() -> void:
var phase_name: String = "PHASE II · LASER STORM" \
if boss.phase == boss.Phase.LASER_STORM else "PHASE I · HATCHLINGS"
status_label.text = "THE WARDEN · %s · EYES %d/%d" % [
@ -31,7 +37,19 @@ func _process(_delta: float) -> void:
hint_label.text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
func _on_boss_phase_changed(_phase: int) -> void:
if boss.phase != boss.Phase.DEFEATED:
_refresh_status()
func _on_boss_health_changed(current: int, maximum: int) -> void:
health_bar.max_value = maximum
health_bar.value = current
_refresh_status()
func _on_boss_defeated() -> void:
game_state.mark_point_captured(game_state.planet_id, 0)
health_bar.value = 0
status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED"
hint_label.text = ""

View file

@ -1,8 +1,27 @@
[gd_scene load_steps=3 format=3]
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://boss/BossEncounter.gd" id="1"]
[ext_resource type="PackedScene" path="res://boss/OctopusBoss.tscn" id="2"]
[sub_resource type="StyleBoxFlat" id="StyleBox_health_background"]
bg_color = Color(0.08, 0.005, 0.055, 0.92)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.45, 0.1, 0.3, 0.95)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBox_health_fill"]
bg_color = Color(0.95, 0.12, 0.48, 0.95)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[node name="BossEncounter" type="Node2D"]
script = ExtResource("1")
@ -11,12 +30,41 @@ script = ExtResource("1")
[node name="EncounterUI" type="CanvasLayer" parent="."]
layer = 2
[node name="HealthCaption" type="Label" parent="EncounterUI"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -520.0
offset_top = 280.0
offset_right = 520.0
offset_bottom = 310.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
theme_override_font_sizes/font_size = 20
text = "THE WARDEN"
horizontal_alignment = 1
[node name="HealthBar" type="ProgressBar" parent="EncounterUI"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -520.0
offset_top = 312.0
offset_right = 520.0
offset_bottom = 344.0
theme_override_styles/background = SubResource("StyleBox_health_background")
theme_override_styles/fill = SubResource("StyleBox_health_fill")
max_value = 18.0
value = 18.0
show_percentage = false
[node name="StatusLabel" type="Label" parent="EncounterUI"]
mouse_filter = 2
offset_left = 20.0
offset_top = 268.0
offset_top = 354.0
offset_right = 700.0
offset_bottom = 310.0
offset_bottom = 396.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
@ -27,9 +75,9 @@ horizontal_alignment = 1
[node name="HintLabel" type="Label" parent="EncounterUI"]
mouse_filter = 2
offset_left = 20.0
offset_top = 310.0
offset_top = 396.0
offset_right = 700.0
offset_bottom = 350.0
offset_bottom = 436.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

View file

@ -2,6 +2,7 @@ class_name BossEye
extends Area2D
signal destroyed
signal health_changed(current: int, maximum: int)
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
@ -10,6 +11,9 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
var hp: int = 3
var opened: bool = false
var phase: float = 0.0
var redraw_left: float = 0.0
const REDRAW_INTERVAL: float = 1.0 / 12.0
func _ready() -> void:
@ -32,6 +36,7 @@ func take_damage(amount: int) -> bool:
if not opened or hp <= 0:
return false
hp -= maxi(amount, 1)
health_changed.emit(maxi(hp, 0), max_hp)
if hp > 0:
queue_redraw()
return false
@ -45,7 +50,9 @@ func take_damage(amount: int) -> bool:
func _process(delta: float) -> void:
phase += delta
if opened:
redraw_left -= delta
if opened and redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()

View file

@ -6,15 +6,19 @@ signal destroyed
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
@export var marked: bool = false
@export var max_hp: int = 2
@export var max_hp: int = 5
@export var speed: float = 245.0
@export var acceleration: float = 520.0
var boss: Node2D
var hp: int = 2
const REDRAW_INTERVAL: float = 1.0 / 12.0
var hp: int = 5
var velocity: Vector2 = Vector2.ZERO
var phase: float = 0.0
var dead: bool = false
var redraw_left: float = 0.0
var fleet: Node2D = null
func _ready() -> void:
@ -23,12 +27,14 @@ func _ready() -> void:
add_to_group("boss_minions")
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
phase = randf() * TAU
fleet = get_tree().get_first_node_in_group("player_fleet")
queue_redraw()
func _physics_process(delta: float) -> void:
phase += delta
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
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:
@ -38,7 +44,10 @@ func _physics_process(delta: float) -> void:
if velocity.length() > 4.0:
rotation = lerp_angle(rotation, velocity.angle() + PI / 2.0,
7.0 * delta)
queue_redraw()
redraw_left -= delta
if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()
func take_damage(amount: int) -> bool:

View file

@ -2,6 +2,7 @@ class_name OctopusBoss
extends Node2D
signal phase_changed(phase: int)
signal health_changed(current: int, maximum: int)
signal defeated
const EYE_SCENE: PackedScene = preload("res://boss/BossEye.tscn")
@ -15,13 +16,14 @@ 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 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 laser_interval: float = 5.5
@export var laser_charge_time: float = 1.6
@export var laser_range: float = 1500.0
@export var laser_interval: float = 3.5
@export var laser_charge_time: float = 0.85
var phase: int = Phase.MINION_WAVES
var wave_timer: float = 0.0
@ -33,6 +35,8 @@ var eyes_destroyed: int = 0
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
func _ready() -> void:
@ -49,8 +53,10 @@ func _build_eyes() -> void:
eye.position = eye_position(i)
eye.rotation = eye.position.angle()
add_child(eye)
eye.health_changed.connect(_on_eye_health_changed)
eye.destroyed.connect(_on_eye_destroyed.bind(i))
eyes.append(eye)
total_max_health += int(eye.max_hp)
func eye_position(index: int) -> Vector2:
@ -65,8 +71,6 @@ func _process(delta: float) -> void:
if phase == Phase.DEFEATED:
return
animation_phase += delta
spawned_minions = spawned_minions.filter(func(node): return is_instance_valid(node))
return_signals = return_signals.filter(func(node): return is_instance_valid(node))
wave_timer -= delta
if wave_timer <= 0.0:
@ -78,20 +82,29 @@ func _process(delta: float) -> void:
if laser_timer <= 0.0:
fire_laser_volley()
laser_timer = laser_interval
queue_redraw()
redraw_left -= delta
if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()
func spawn_wave() -> void:
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
var count: int = maxi(3, minions_per_wave - (2 if phase == Phase.LASER_STORM else 0))
_prune_invalid(spawned_minions)
var wanted: int = maxi(3,
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
for i in range(count):
var minion: Area2D = MINION_SCENE.instantiate()
minion.marked = i == marked_slot
minion.boss = self
get_parent().add_child(minion)
minion.tree_exiting.connect(_forget_minion.bind(minion))
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.
@ -107,6 +120,7 @@ func accept_marked_kill(from: Vector2) -> void:
var returning: Node2D = RETURN_SCENE.instantiate()
returning.target = self
get_parent().add_child(returning)
returning.tree_exiting.connect(_forget_return.bind(returning))
returning.global_position = from
return_signals.append(returning)
@ -124,16 +138,33 @@ func open_next_eye() -> bool:
func _on_eye_destroyed(_index: int) -> void:
eyes_destroyed += 1
health_changed.emit(current_health(), max_health())
if eyes_destroyed >= EYE_COUNT:
_defeat()
return
if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES:
phase = Phase.LASER_STORM
laser_timer = 0.35
laser_timer = 0.2
phase_changed.emit(phase)
queue_redraw()
func _on_eye_health_changed(_current: int, _maximum: int) -> void:
health_changed.emit(current_health(), max_health())
func current_health() -> int:
var total: int = 0
for eye in eyes:
if is_instance_valid(eye):
total += maxi(int(eye.hp), 0)
return total
func max_health() -> int:
return total_max_health
func fire_laser_volley() -> void:
if phase != Phase.LASER_STORM:
return
@ -147,7 +178,7 @@ func fire_laser_volley() -> void:
for spread in [-0.17, 0.0, 0.17]:
var beam: Node2D = LASER_SCENE.instantiate()
beam.charge_time = laser_charge_time
beam.beam_range = laser_range
beam.infinite_range = true
beam.beam_half_width = 24.0
beam.damage = 1
beam.color = Color(1.0, 0.12, 0.58)
@ -156,6 +187,20 @@ func fire_laser_volley() -> void:
beam.launch(global_position, toward.rotated(float(spread)))
func _forget_minion(minion: Node) -> void:
spawned_minions.erase(minion)
func _forget_return(returning: Node) -> void:
return_signals.erase(returning)
func _prune_invalid(nodes: Array) -> void:
for i in range(nodes.size() - 1, -1, -1):
if not is_instance_valid(nodes[i]) or nodes[i].is_queued_for_deletion():
nodes.remove_at(i)
func _defeat() -> void:
if phase == Phase.DEFEATED:
return