Add tentacled boss planet encounter

This commit is contained in:
Loïc Wisniewski 2026-09-03 10:33:05 +02:00
commit 40f284dcdd
38 changed files with 1051 additions and 19 deletions

37
boss/BossEncounter.gd Normal file
View file

@ -0,0 +1,37 @@
class_name BossEncounter
extends Node2D
@onready var boss: Node2D = $Boss
@onready var status_label: Label = $EncounterUI/StatusLabel
@onready var hint_label: Label = $EncounterUI/HintLabel
@onready var game_state: Node = get_node("/root/GameState")
func _ready() -> void:
# One persistent objective lets boss victory flow through the same conquest
# state as authored capture-point maps without pretending the boss is a
# configurable capture point.
game_state.register_capture_points(game_state.planet_id, 1,
game_state.planet_definition_id)
if game_state.is_planet_conquered(game_state.planet_id):
boss.suppress_as_defeated()
status_label.text = "THE WARDEN IS DEAD"
hint_label.text = ""
return
boss.defeated.connect(_on_boss_defeated)
func _process(_delta: float) -> void:
if not is_instance_valid(boss) or boss.phase == boss.Phase.DEFEATED:
return
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" % [
phase_name, boss.eyes_destroyed, boss.EYE_COUNT]
hint_label.text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
func _on_boss_defeated() -> void:
game_state.mark_point_captured(game_state.planet_id, 0)
status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED"
hint_label.text = ""

View file

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

38
boss/BossEncounter.tscn Normal file
View file

@ -0,0 +1,38 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://boss/BossEncounter.gd" id="1"]
[ext_resource type="PackedScene" path="res://boss/OctopusBoss.tscn" id="2"]
[node name="BossEncounter" type="Node2D"]
script = ExtResource("1")
[node name="Boss" parent="." instance=ExtResource("2")]
[node name="EncounterUI" type="CanvasLayer" parent="."]
layer = 2
[node name="StatusLabel" type="Label" parent="EncounterUI"]
mouse_filter = 2
offset_left = 20.0
offset_top = 268.0
offset_right = 700.0
offset_bottom = 310.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 = 24
text = "THE WARDEN"
horizontal_alignment = 1
[node name="HintLabel" type="Label" parent="EncounterUI"]
mouse_filter = 2
offset_left = 20.0
offset_top = 310.0
offset_right = 700.0
offset_bottom = 350.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
text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
horizontal_alignment = 1

68
boss/BossEye.gd Normal file
View file

@ -0,0 +1,68 @@
class_name BossEye
extends Area2D
signal destroyed
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
@export var max_hp: int = 3
var hp: int = 3
var opened: bool = false
var phase: float = 0.0
func _ready() -> void:
hp = max_hp
$CollisionShape2D.disabled = true
queue_redraw()
func open_eye() -> void:
if opened:
return
opened = true
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
add_to_group("boss_eyes")
$CollisionShape2D.set_deferred("disabled", false)
queue_redraw()
func take_damage(amount: int) -> bool:
if not opened or hp <= 0:
return false
hp -= maxi(amount, 1)
if hp > 0:
queue_redraw()
return false
hp = 0
EnemyExplosionVFX.spawn(get_tree().root, global_position)
Sfx.play(get_tree(), Sfx.ENEMY_DEATH)
destroyed.emit()
queue_free()
return true
func _process(delta: float) -> void:
phase += delta
if opened:
queue_redraw()
func _draw() -> void:
var flesh := Color(0.95, 0.18, 0.58)
var dark := Color(0.08, 0.005, 0.055)
if not opened:
# A sealed lid at the end of the tentacle.
NeonDraw.draw_glowing_line(self, Vector2(-20, -5), Vector2(20, 5),
flesh, 0.5, 0.75, dark)
NeonDraw.draw_glowing_line(self, Vector2(-20, 5), Vector2(20, -5),
flesh, 0.5, 0.75, dark)
return
var pulse: float = 0.78 + 0.22 * sin(phase * 5.0)
draw_set_transform(Vector2.ZERO, 0.0, Vector2(1.0, 0.66))
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 25.0,
Color(0.25, 0.92, 1.0), pulse, dark)
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 10.0,
Color(1.0, 0.9, 0.3), 1.0, dark)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)

1
boss/BossEye.gd.uid Normal file
View file

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

12
boss/BossEye.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://boss/BossEye.gd" id="1"]
[sub_resource type="CircleShape2D" id="CircleShape_eye"]
radius = 25.0
[node name="BossEye" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape_eye")

96
boss/BossMinion.gd Normal file
View file

@ -0,0 +1,96 @@
class_name BossMinion
extends Area2D
signal destroyed
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
@export var marked: bool = false
@export var max_hp: int = 2
@export var speed: float = 245.0
@export var acceleration: float = 520.0
var boss: Node2D
var hp: int = 2
var velocity: Vector2 = Vector2.ZERO
var phase: float = 0.0
var dead: bool = false
func _ready() -> void:
hp = max_hp
add_to_group("enemies")
add_to_group("boss_minions")
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
phase = randf() * TAU
queue_redraw()
func _physics_process(delta: float) -> void:
phase += delta
var fleet: Node2D = 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)
queue_redraw()
func take_damage(amount: int) -> bool:
if dead:
return false
hp -= maxi(amount, 1)
if hp > 0:
return false
_die(true)
return true
func collide_with_player_vessel() -> void:
_die(false)
func retire() -> void:
if dead:
return
dead = true
hp = 0
destroyed.emit()
queue_free()
func _die(killed_by_player: bool) -> void:
if dead:
return
dead = true
hp = 0
if killed_by_player and marked and is_instance_valid(boss):
boss.accept_marked_kill(global_position)
EnemyExplosionVFX.spawn(get_tree().root, global_position)
Sfx.play(get_tree(), Sfx.ENEMY_DEATH)
destroyed.emit()
queue_free()
func _draw() -> void:
var glow := Color(1.0, 0.78, 0.18) if marked else Color(0.9, 0.16, 0.58)
var core := Color(0.13, 0.01, 0.09)
# A small bulb with four streaming arms: recognisably related to the boss,
# but compact enough to read among an entire wave.
draw_set_transform(Vector2.ZERO, 0.0, Vector2(1.0, 0.76))
NeonDraw.draw_glowing_circle(self, Vector2(0, -7), 13.0, glow, 1.0, core)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
for i in range(4):
var x: float = -12.0 + float(i) * 8.0
var curl: float = sin(phase * 5.0 + float(i)) * 5.0
NeonDraw.draw_glowing_path(self, PackedVector2Array([
Vector2(x, 1), Vector2(x + curl * 0.35, 10), Vector2(x + curl, 21),
]), glow, 0.85, 0.35, core)
if marked:
NeonDraw.draw_glowing_circle(self, Vector2(0, -8), 4.0,
Color.WHITE, 1.0, core)

1
boss/BossMinion.gd.uid Normal file
View file

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

12
boss/BossMinion.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://boss/BossMinion.gd" id="1"]
[sub_resource type="CircleShape2D" id="CircleShape_minion"]
radius = 16.0
[node name="BossMinion" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape_minion")

39
boss/BossReturnSignal.gd Normal file
View file

@ -0,0 +1,39 @@
class_name BossReturnSignal
extends Node2D
@export var speed: float = 520.0
var target: Node2D
var trail: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
add_to_group("boss_returns")
trail.append(global_position)
queue_redraw()
func _process(delta: float) -> void:
if not is_instance_valid(target):
queue_free()
return
var destination: Vector2 = target.global_position
if global_position.distance_to(destination) <= speed * delta:
global_position = destination
target.open_next_eye()
queue_free()
return
global_position = global_position.move_toward(destination, speed * delta)
trail.append(global_position)
while trail.size() > 14:
trail.remove_at(0)
queue_redraw()
func _draw() -> void:
for i in range(1, trail.size()):
var alpha: float = float(i) / float(trail.size())
draw_line(trail[i - 1] - global_position, trail[i] - global_position,
Color(1.0, 0.82, 0.22, alpha * 0.7), 3.0 + alpha * 5.0, true)
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 9.0,
Color(1.0, 0.82, 0.22), 1.0, Color(0.16, 0.04, 0.01))

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://boss/BossReturnSignal.gd" id="1"]
[node name="BossReturnSignal" type="Node2D"]
script = ExtResource("1")

213
boss/OctopusBoss.gd Normal file
View file

@ -0,0 +1,213 @@
class_name OctopusBoss
extends Node2D
signal phase_changed(phase: int)
signal defeated
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")
enum Phase { MINION_WAVES, LASER_STORM, DEFEATED }
const EYE_COUNT: int = 6
const PHASE_TWO_EYES: int = 3
const BODY_RADIUS: float = 86.0
const TENTACLE_LENGTH: float = 235.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
var phase: int = Phase.MINION_WAVES
var wave_timer: float = 0.0
var laser_timer: float = 0.0
var wave_index: int = 0
var eyes: Array = []
var eyes_opened: int = 0
var eyes_destroyed: int = 0
var spawned_minions: Array = []
var return_signals: Array = []
var animation_phase: float = 0.0
func _ready() -> void:
add_to_group("bosses")
wave_timer = first_wave_delay
laser_timer = laser_interval
_build_eyes()
queue_redraw()
func _build_eyes() -> void:
for i in range(EYE_COUNT):
var eye: Area2D = EYE_SCENE.instantiate()
eye.position = eye_position(i)
eye.rotation = eye.position.angle()
add_child(eye)
eye.destroyed.connect(_on_eye_destroyed.bind(i))
eyes.append(eye)
func eye_position(index: int) -> Vector2:
var angle: float = -PI / 2.0 + TAU * float(index) / float(EYE_COUNT)
# Alternating reach keeps the silhouette organic without randomising the
# actual weak-point layout between attempts.
var reach: float = TENTACLE_LENGTH * (0.88 if index % 2 == 0 else 1.0)
return Vector2.from_angle(angle) * reach
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:
spawn_wave()
wave_timer = wave_interval * (1.25 if phase == Phase.LASER_STORM else 1.0)
if phase == Phase.LASER_STORM:
laser_timer -= delta
if laser_timer <= 0.0:
fire_laser_volley()
laser_timer = laser_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))
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)
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
spawned_minions.append(minion)
wave_index += 1
func accept_marked_kill(from: Vector2) -> void:
if phase == Phase.DEFEATED or get_parent() == null:
return
var returning: Node2D = RETURN_SCENE.instantiate()
returning.target = self
get_parent().add_child(returning)
returning.global_position = from
return_signals.append(returning)
func open_next_eye() -> bool:
if phase == Phase.DEFEATED:
return false
for eye in eyes:
if is_instance_valid(eye) and not bool(eye.opened):
eye.open_eye()
eyes_opened += 1
return true
return false
func _on_eye_destroyed(_index: int) -> void:
eyes_destroyed += 1
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
phase_changed.emit(phase)
queue_redraw()
func fire_laser_volley() -> void:
if phase != Phase.LASER_STORM:
return
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return
var toward: Vector2 = fleet.get_centroid() - global_position
if toward.length() < 1.0:
return
# Three locked, dodgeable lanes form the second phase's distinct pattern.
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.beam_half_width = 24.0
beam.damage = 1
beam.color = Color(1.0, 0.12, 0.58)
get_tree().root.add_child(beam)
beam.add_to_group("boss_lasers")
beam.launch(global_position, toward.rotated(float(spread)))
func _defeat() -> void:
if phase == Phase.DEFEATED:
return
phase = Phase.DEFEATED
for minion in spawned_minions:
if is_instance_valid(minion):
minion.retire()
for returning in return_signals:
if is_instance_valid(returning):
returning.queue_free()
EnemyExplosionVFX.spawn(get_tree().root, global_position)
Sfx.play(get_tree(), Sfx.ENEMY_DEATH)
visible = false
phase_changed.emit(phase)
defeated.emit()
func suppress_as_defeated() -> void:
phase = Phase.DEFEATED
visible = false
set_process(false)
func _draw() -> void:
if phase == Phase.DEFEATED:
return
var flesh := Color(0.95, 0.18, 0.58)
var phase_color := Color(1.0, 0.1, 0.32) if phase == Phase.LASER_STORM \
else flesh
var dark := Color(0.075, 0.004, 0.05)
for i in range(EYE_COUNT):
var end: Vector2 = eye_position(i)
var angle: float = end.angle()
var side := Vector2.from_angle(angle + PI / 2.0)
var points := PackedVector2Array()
for step in range(9):
var t: float = float(step) / 8.0
var sway: float = sin(animation_phase * 1.7 + float(i) * 1.4 + t * PI * 2.0)
points.append(end * t + side * sway * 18.0 * sin(t * PI))
NeonDraw.draw_glowing_path(self, points, phase_color, 0.8, 0.8, dark)
var body := PackedVector2Array()
for i in range(28):
var angle: float = TAU * float(i) / 28.0
var pulse: float = 1.0 + 0.045 * sin(animation_phase * 2.4 + float(i) * 1.9)
body.append(Vector2.from_angle(angle) * BODY_RADIUS * pulse)
NeonDraw.draw_glowing_polygon(self, body, phase_color, 0.92, dark)
# 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))
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)
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
boss/OctopusBoss.gd.uid Normal file
View file

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

9
boss/OctopusBoss.tscn Normal file
View file

@ -0,0 +1,9 @@
[gd_scene load_steps=2 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)
z_index = 2
script = ExtResource("1")