From 68985316e50978b9ac3d55f0f8eb2e4f83305f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Thu, 3 Sep 2026 11:49:51 +0200 Subject: [PATCH] Tune and optimize boss encounter --- boss/BossEncounter.gd | 24 ++++++++++-- boss/BossEncounter.tscn | 58 +++++++++++++++++++++++++--- boss/BossEye.gd | 9 ++++- boss/BossMinion.gd | 17 +++++++-- boss/OctopusBoss.gd | 63 ++++++++++++++++++++++++++----- enemies/EnemyBigLaserBeam.gd | 50 +++++++++++++++++------- planets/BossPlanetVisual.gd | 8 +++- shaders/boss_surface.gdshader | 47 ----------------------- shaders/boss_surface.gdshader.uid | 1 - surface/BossSurfaceProfile.tres | 6 ++- surface/PlanetSurfaceProfile.gd | 4 ++ surface/SurfaceBackground.gd | 6 +++ surface/boss_membrane.svg | 22 +++++++++++ surface/boss_membrane.svg.import | 43 +++++++++++++++++++++ tests/_boss_planet_preview.gd | 7 +++- tests/test_boss_planet.gd | 39 +++++++++++++++++++ 16 files changed, 316 insertions(+), 88 deletions(-) delete mode 100644 shaders/boss_surface.gdshader delete mode 100644 shaders/boss_surface.gdshader.uid create mode 100644 surface/boss_membrane.svg create mode 100644 surface/boss_membrane.svg.import diff --git a/boss/BossEncounter.gd b/boss/BossEncounter.gd index 6f63aa0..1552a65 100644 --- a/boss/BossEncounter.gd +++ b/boss/BossEncounter.gd @@ -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 = "" diff --git a/boss/BossEncounter.tscn b/boss/BossEncounter.tscn index f724766..cb7a750 100644 --- a/boss/BossEncounter.tscn +++ b/boss/BossEncounter.tscn @@ -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 diff --git a/boss/BossEye.gd b/boss/BossEye.gd index 9ae5e83..a15a2bd 100644 --- a/boss/BossEye.gd +++ b/boss/BossEye.gd @@ -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() diff --git a/boss/BossMinion.gd b/boss/BossMinion.gd index b580785..3323527 100644 --- a/boss/BossMinion.gd +++ b/boss/BossMinion.gd @@ -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: diff --git a/boss/OctopusBoss.gd b/boss/OctopusBoss.gd index 61341af..0f72931 100644 --- a/boss/OctopusBoss.gd +++ b/boss/OctopusBoss.gd @@ -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 diff --git a/enemies/EnemyBigLaserBeam.gd b/enemies/EnemyBigLaserBeam.gd index 3478536..bd98bdf 100644 --- a/enemies/EnemyBigLaserBeam.gd +++ b/enemies/EnemyBigLaserBeam.gd @@ -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,15 +48,20 @@ 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() - queue_redraw() + if redraw_left <= 0.0: + redraw_left = REDRAW_INTERVAL + queue_redraw() Phase.FLASH: flash_time -= delta - queue_redraw() + if redraw_left <= 0.0: + redraw_left = REDRAW_INTERVAL + queue_redraw() if flash_time <= 0.0: finished.emit() queue_free() @@ -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 diff --git a/planets/BossPlanetVisual.gd b/planets/BossPlanetVisual.gd index bc331c9..abd9e95 100644 --- a/planets/BossPlanetVisual.gd +++ b/planets/BossPlanetVisual.gd @@ -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,7 +24,10 @@ func extractor_anchor() -> Vector2: func _process(delta: float) -> void: phase += delta - queue_redraw() + redraw_left -= delta + if redraw_left <= 0.0: + redraw_left = REDRAW_INTERVAL + queue_redraw() func _draw() -> void: diff --git a/shaders/boss_surface.gdshader b/shaders/boss_surface.gdshader deleted file mode 100644 index a9f368e..0000000 --- a/shaders/boss_surface.gdshader +++ /dev/null @@ -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); -} diff --git a/shaders/boss_surface.gdshader.uid b/shaders/boss_surface.gdshader.uid deleted file mode 100644 index 3a75748..0000000 --- a/shaders/boss_surface.gdshader.uid +++ /dev/null @@ -1 +0,0 @@ -uid://csf5umhh3dqft diff --git a/surface/BossSurfaceProfile.tres b/surface/BossSurfaceProfile.tres index 6d59bdb..24b8cda 100644 --- a/surface/BossSurfaceProfile.tres +++ b/surface/BossSurfaceProfile.tres @@ -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) diff --git a/surface/PlanetSurfaceProfile.gd b/surface/PlanetSurfaceProfile.gd index cf550dc..cee1408 100644 --- a/surface/PlanetSurfaceProfile.gd +++ b/surface/PlanetSurfaceProfile.gd @@ -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) diff --git a/surface/SurfaceBackground.gd b/surface/SurfaceBackground.gd index fd5cdf5..d507567 100644 --- a/surface/SurfaceBackground.gd +++ b/surface/SurfaceBackground.gd @@ -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() diff --git a/surface/boss_membrane.svg b/surface/boss_membrane.svg new file mode 100644 index 0000000..d18bf70 --- /dev/null +++ b/surface/boss_membrane.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/surface/boss_membrane.svg.import b/surface/boss_membrane.svg.import new file mode 100644 index 0000000..8341c6f --- /dev/null +++ b/surface/boss_membrane.svg.import @@ -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 diff --git a/tests/_boss_planet_preview.gd b/tests/_boss_planet_preview.gd index 6547f29..8a77480 100644 --- a/tests/_boss_planet_preview.gd +++ b/tests/_boss_planet_preview.gd @@ -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 diff --git a/tests/test_boss_planet.gd b/tests/test_boss_planet.gd index 79a772c..f52ad43 100644 --- a/tests/test_boss_planet.gd +++ b/tests/test_boss_planet.gd @@ -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()