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,6 +44,9 @@ func _physics_process(delta: float) -> void:
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()

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,6 +82,9 @@ func _process(delta: float) -> void:
if laser_timer <= 0.0:
fire_laser_volley()
laser_timer = laser_interval
redraw_left -= delta
if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()
@ -85,13 +92,19 @@ 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

View file

@ -12,27 +12,29 @@ extends Node2D
# the fleet's position while charging.
#
# Damage: every ship in group "player_ships" whose perpendicular distance
# to the beam segment is within beam_half_width, AND whose projection along
# the beam falls inside [0, beam_range], takes Ship.take_damage(damage) -
# same all-or-nothing shield semantics as every other damage source in this
# game (Ship.take_damage already handles the absorb_hit check itself).
# to the beam is within beam_half_width takes Ship.take_damage(damage). The
# ordinary mothership beam remains a finite segment; bosses opt into a ray
# with no upper distance bound. Ship.take_damage handles shields itself.
signal finished
@export var charge_time: float = 2.5
@export var beam_range: float = 1200.0
@export var infinite_range: bool = false
@export var beam_half_width: float = 35.0
@export var damage: int = 3
@export var color: Color = Color(0.55, 0.05, 0.85)
enum Phase { CHARGING, FLASH }
const FLASH_DURATION: float = 0.25
const REDRAW_INTERVAL: float = 1.0 / 30.0
var phase: int = Phase.CHARGING
var origin: Vector2 = Vector2.ZERO
var aim_direction: Vector2 = Vector2.RIGHT
var charge_left: float = 0.0
var flash_time: float = 0.0
var redraw_left: float = 0.0
func launch(from: Vector2, toward: Vector2) -> void:
@ -46,14 +48,19 @@ func launch(from: Vector2, toward: Vector2) -> void:
func _process(delta: float) -> void:
redraw_left -= delta
match phase:
Phase.CHARGING:
charge_left -= delta
if charge_left <= 0.0:
_fire()
if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()
Phase.FLASH:
flash_time -= delta
if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()
if flash_time <= 0.0:
finished.emit()
@ -66,16 +73,19 @@ func _fire() -> void:
for ship in get_tree().get_nodes_in_group("player_ships"):
if not is_instance_valid(ship):
continue
var to_ship: Vector2 = ship.global_position - origin
var along: float = to_ship.dot(aim_direction)
if along < 0.0 or along > beam_range:
continue
var perpendicular: float = (to_ship - aim_direction * along).length()
if perpendicular <= beam_half_width:
if reaches_point(ship.global_position):
ship.take_damage(damage)
queue_redraw()
func reaches_point(world_point: Vector2) -> bool:
var offset: Vector2 = world_point - origin
var along: float = offset.dot(aim_direction)
if along < 0.0 or (not infinite_range and along > beam_range):
return false
return (offset - aim_direction * along).length() <= beam_half_width
func _draw() -> void:
# Drawn in local space, rotated in launch() so local +X already points
# along the locked aim_direction - the same reason the beam segment's
@ -100,7 +110,21 @@ func _draw_beam_line(width: float, alpha: float) -> void:
# Three-layer glow along local +X - the same soft-halo / core / bright-
# hairline structure as ArtilleryShell._draw_ring, adapted from a ring
# to a straight line.
var to: Vector2 = Vector2(beam_range, 0.0)
var length: float = _visible_ray_length() if infinite_range else beam_range
var to: Vector2 = Vector2(length, 0.0)
draw_line(Vector2.ZERO, to, Color(color, 0.16 * alpha), width * 2.6, true)
draw_line(Vector2.ZERO, to, Color(color, 0.5 * alpha), width, true)
draw_line(Vector2.ZERO, to, Color(1.0, 0.92, 0.98, 0.9 * alpha), width * 0.35, true)
func _visible_ray_length() -> float:
# Collision is mathematically unbounded, but rasterizing a 20,000-unit
# primitive wastes fill work outside the screen. Reaching beyond the
# furthest viewport corner looks identical and follows a moving camera.
var viewport_world_size: Vector2 = get_viewport_rect().size
var camera: Camera2D = get_viewport().get_camera_2d()
if camera == null:
return viewport_world_size.length() + 256.0
viewport_world_size /= camera.zoom
return origin.distance_to(camera.global_position) \
+ viewport_world_size.length() * 0.5 + 256.0

View file

@ -8,6 +8,9 @@ const VEIN_COLOR := Color(0.25, 0.9, 1.0)
const CORE_COLOR := Color(0.11, 0.015, 0.08)
var phase: float = 0.0
var redraw_left: float = 0.0
const REDRAW_INTERVAL: float = 1.0 / 12.0
func configure(shape_seed: int) -> void:
@ -21,6 +24,9 @@ func extractor_anchor() -> Vector2:
func _process(delta: float) -> void:
phase += delta
redraw_left -= delta
if redraw_left <= 0.0:
redraw_left = REDRAW_INTERVAL
queue_redraw()

View file

@ -1,47 +0,0 @@
shader_type canvas_item;
// An organic membrane rather than terrain: broad folds move below branching
// cyan veins. It follows SurfaceBackground's shared camera-uniform contract.
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
uniform float camera_zoom = 1.0;
uniform vec2 viewport_size = vec2(720.0, 1280.0);
uniform float cell_size = 220.0;
uniform float tile_cells = 32.0;
uniform vec4 base_color : source_color = vec4(0.055, 0.008, 0.05, 1.0);
uniform vec4 crater_color : source_color = vec4(0.16, 0.018, 0.10, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.16, 0.66, 0.72, 1.0);
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
vec2 wrapped = mod(camera_cell + cell_rel, tile_cells);
vec2 p = (wrapped + local) / tile_cells * 6.28318530718;
float fold_a = sin(p.x * 3.0 + sin(p.y * 2.0 - TIME * 0.16) * 1.1);
// Integer frequencies on both axes keep the 32-cell wrapped field
// seamless where p crosses from 2*PI back to zero.
float fold_b = sin(p.y * 4.0 - p.x * 2.0 + TIME * 0.11);
float tissue = 0.5 + 0.25 * fold_a + 0.25 * fold_b;
vec3 color = mix(base_color.rgb, crater_color.rgb, smoothstep(0.18, 0.85, tissue));
float vein_a = abs(sin(p.x * 5.0 + sin(p.y * 3.0 + TIME * 0.22)));
float vein_b = abs(sin(p.y * 6.0 - p.x * 2.0 - TIME * 0.18));
// Veins are environmental texture, not attack telegraphs. Keep them thin
// and subdued so the boss's bright pink laser lanes remain unambiguous.
float vein = 1.0 - smoothstep(0.0, 0.028, min(vein_a, vein_b));
float pulse = 0.55 + 0.45 * sin(TIME * 1.8 + p.x + p.y);
color = mix(color, highlight_color.rgb, vein * (0.10 + pulse * 0.20));
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
COLOR = vec4(color + vec3(dither), 1.0);
}

View file

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

View file

@ -1,8 +1,9 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3]
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=5 format=3]
[ext_resource type="Script" path="res://surface/PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="Shader" path="res://shaders/boss_surface.gdshader" id="2"]
[ext_resource type="Shader" path="res://shaders/lunar_tiled.gdshader" id="2"]
[ext_resource type="PackedScene" path="res://boss/BossEncounter.tscn" id="3"]
[ext_resource type="Texture2D" path="res://surface/boss_membrane.svg" id="4"]
[resource]
script = ExtResource("1")
@ -11,6 +12,7 @@ encounter_scene = ExtResource("3")
editor_visible = false
show_capture_status = false
background_shader = ExtResource("2")
background_field_texture = ExtResource("4")
base_color = Color(0.055, 0.008, 0.05, 1)
crater_color = Color(0.16, 0.018, 0.10, 1)
highlight_color = Color(0.16, 0.66, 0.72, 1)

View file

@ -17,6 +17,10 @@ extends Resource
# the game and the setup editor drift apart, since each scene "already had" a
# different shader. SurfaceBackground.apply_profile() is the only consumer.
@export var background_shader: Shader = null
# Optional ready-made field for a tiled background shader. Procedural rocky
# terrain still bakes once; authored surfaces can avoid both that load spike
# and all per-frame procedural fragment work by shipping their tile directly.
@export var background_field_texture: Texture2D = null
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)
@export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0)

View file

@ -50,6 +50,7 @@ var camera: Camera2D = null
# differing only in colour, so keying on the shader alone would show void's
# palette with rocky's crater texture.
var _baked_key: Array = []
var _provided_field_texture: Texture2D = null
# Applies a surface profile to this backdrop: which shader draws it, and the
@ -68,9 +69,14 @@ func apply_profile(profile: Resource) -> void:
highlight_color = profile.highlight_color
if profile.background_shader != null:
material.shader = profile.background_shader
_provided_field_texture = profile.background_field_texture
material.set_shader_parameter("base_color", base_color)
material.set_shader_parameter("crater_color", crater_color)
material.set_shader_parameter("highlight_color", highlight_color)
if _provided_field_texture != null and _material_wants_baked_field():
material.set_shader_parameter("field_texture", _provided_field_texture)
_baked_key = [material.shader, _provided_field_texture]
return
_bake_field()

22
surface/boss_membrane.svg Normal file
View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#21061c"/>
<g fill="#451139" opacity="0.5">
<ellipse cx="112" cy="116" rx="104" ry="78"/>
<ellipse cx="374" cy="350" rx="126" ry="96"/>
<ellipse cx="246" cy="246" rx="72" ry="118" opacity="0.42"/>
</g>
<g fill="none" stroke-linecap="round">
<path d="M0 68 C120 18 164 128 268 76 S420 20 512 68" stroke="#2b6972" stroke-width="7" opacity="0.42"/>
<path d="M0 260 C102 214 176 324 282 270 S420 214 512 260" stroke="#245d68" stroke-width="6" opacity="0.35"/>
<path d="M0 452 C118 402 170 506 276 460 S418 404 512 452" stroke="#2b6972" stroke-width="6" opacity="0.38"/>
<path d="M94 0 C46 116 152 178 102 286 S46 420 94 512" stroke="#326f76" stroke-width="6" opacity="0.34"/>
<path d="M354 0 C300 102 410 180 356 276 S304 416 354 512" stroke="#285f69" stroke-width="7" opacity="0.4"/>
</g>
<g fill="#55a9ae" opacity="0.3">
<circle cx="268" cy="76" r="8"/>
<circle cx="282" cy="270" r="7"/>
<circle cx="276" cy="460" r="8"/>
<circle cx="102" cy="286" r="7"/>
<circle cx="356" cy="276" r="9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bajkflkc0r3cp"
path="res://.godot/imported/boss_membrane.svg-6482a210571f1472430bfb4ecba98f3c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://surface/boss_membrane.svg"
dest_files=["res://.godot/imported/boss_membrane.svg-6482a210571f1472430bfb4ecba98f3c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -35,15 +35,18 @@ func _process(_delta: float) -> void:
var boss: Node2D = encounter.get_node("Boss")
boss.wave_timer = 999.0
boss.laser_timer = 999.0
boss.phase = boss.Phase.LASER_STORM
for i in range(3):
boss.eyes[i].open_eye()
boss.eyes_opened += 1
boss.eyes[i].take_damage(999)
# Show one of the remaining weak points in its attackable state while the
# aggregate bar truthfully sits at half health.
boss.open_next_eye()
boss.spawn_wave()
for minion in get_tree().get_nodes_in_group("boss_minions"):
minion.speed = 0.0
minion.acceleration = 0.0
boss.laser_charge_time = 999.0
boss.fire_laser_volley()
encounter._process(0.0)
encounter._refresh_status()
get_tree().paused = true

View file

@ -61,6 +61,15 @@ func _process(delta: float) -> bool:
var profile: Resource = surface_profiles.get_profile("boss")
if profile == null or profile.encounter_scene == null:
return _fail("boss surface has no scripted encounter")
if profile.background_field_texture == null \
or not String(profile.background_shader.resource_path).ends_with(
"lunar_tiled.gdshader"):
return _fail("boss backdrop still performs procedural rendering during combat")
var composed_background: ShaderMaterial = surface.get_node(
"BackgroundLayer/Background").material
if composed_background.get_shader_parameter("field_texture") \
!= profile.background_field_texture:
return _fail("boss backdrop did not bind its ready-made field texture")
if profile.editor_visible or "boss" in surface_profiles.editor_profile_ids():
return _fail("boss surface is configurable in the map editor")
state.dev_mode = true
@ -103,6 +112,10 @@ func _process(delta: float) -> bool:
boss = encounter.get_node("Boss")
boss.wave_timer = 999.0
boss.laser_timer = 999.0
var health_bar: ProgressBar = encounter.get_node("EncounterUI/HealthBar")
if health_bar.max_value != boss.max_health() \
or health_bar.value != boss.current_health():
return _fail("aggregate boss health bar did not start full")
if boss.eyes.size() != boss.EYE_COUNT:
return _fail("boss has %d tentacle eyes, expected %d" \
% [boss.eyes.size(), boss.EYE_COUNT])
@ -126,6 +139,11 @@ func _process(delta: float) -> bool:
minion.take_damage(999)
if marked == null:
return _fail("phase-one wave has no marked hatchling")
if marked.max_hp < 5:
return _fail("boss hatchlings still have only %d health" % marked.max_hp)
marked.take_damage(1)
if not is_instance_valid(marked) or marked.hp != marked.max_hp - 1:
return _fail("a hatchling did not survive a normal weapon hit")
if not get_nodes_in_group("boss_returns").is_empty():
return _fail("an ordinary hatchling returned energy to the boss")
marked.take_damage(999)
@ -156,6 +174,9 @@ func _process(delta: float) -> bool:
eye.take_damage(1)
if eye.hp != hp_before - 1:
return _fail("an open eye ignored weapon damage")
if encounter.health_bar.value != boss.current_health() \
or encounter.health_bar.value >= encounter.health_bar.max_value:
return _fail("total boss health bar ignored eye damage")
eye.take_damage(999)
if boss.eyes_destroyed != 1:
return _fail("destroying the first eye did not damage the boss")
@ -188,12 +209,19 @@ func _process(delta: float) -> bool:
var beams: Array = get_nodes_in_group("boss_lasers")
if beams.size() != 3:
return _fail("laser phase launched %d beams, expected three lanes" % beams.size())
if boss.laser_interval >= 5.5 or boss.laser_charge_time >= 1.6:
return _fail("boss lasers retained their old slow cadence")
if beams[0].aim_direction.is_equal_approx(beams[1].aim_direction) \
or beams[1].aim_direction.is_equal_approx(beams[2].aim_direction):
return _fail("laser volley lanes all aim in the same direction")
for beam in beams:
if beam.phase != 0 or beam.charge_left <= 0.0:
return _fail("boss laser was not telegraphed before firing")
if not beam.infinite_range or not beam.reaches_point(
beam.origin + beam.aim_direction * 1000000.0):
return _fail("boss laser ray stops at a finite range")
if beam.reaches_point(beam.origin - beam.aim_direction * 10.0):
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")
@ -204,6 +232,14 @@ func _process(delta: float) -> bool:
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:
return _fail("boss waves can accumulate without a rendering/gameplay cap")
for minion in boss.spawned_minions.duplicate():
if is_instance_valid(minion):
minion.retire()
print("three broken eyes started a telegraphed three-lane laser phase")
# --- F. all eyes -> persistent victory ------------------------
@ -214,6 +250,9 @@ func _process(delta: float) -> bool:
return _fail("breaking every eye 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")
surface.free()