diff --git a/boss/BossEye.gd b/boss/BossEye.gd index 738d3ae..d26e161 100644 --- a/boss/BossEye.gd +++ b/boss/BossEye.gd @@ -5,6 +5,8 @@ signal destroyed signal health_changed(current: int, maximum: int) const COMBAT_TARGET = preload("res://core/CombatTarget.gd") +const IRIS_COLOR := Color(0.25, 0.92, 1.0) +const PUPIL_COLOR := Color(1.0, 0.9, 0.3) @export var max_hp: int = 5 @@ -69,7 +71,7 @@ func _draw() -> void: 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) + IRIS_COLOR, pulse, dark) NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 10.0, - Color(1.0, 0.9, 0.3), 1.0, dark) + PUPIL_COLOR, 1.0, dark) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) diff --git a/boss/OctopusBoss.gd b/boss/OctopusBoss.gd index c26f60b..461ce2d 100644 --- a/boss/OctopusBoss.gd +++ b/boss/OctopusBoss.gd @@ -21,12 +21,20 @@ 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 +const ARMOURED_IRIS_COLOR := Color(0.72, 0.22, 1.0) +const ARMOURED_PUPIL_COLOR := Color(0.18, 1.0, 0.62) +const EXPOSED_IRIS_COLOR := Color(1.0, 0.56, 0.08) +const EXPOSED_PUPIL_COLOR := Color(1.0, 0.94, 0.42) @export var first_wave_delay: float = 1.5 @export var wave_interval: float = 6.0 @export var minions_per_wave: int = 7 -@export var laser_interval: float = 3.5 -@export var laser_charge_time: float = 0.85 +@export var laser_interval: float = 2.25 +@export var laser_charge_time: float = 0.7 +@export var move_speed: float = 115.0 +@export var move_acceleration: float = 180.0 +@export var preferred_fleet_range: float = 880.0 +@export var strafe_speed: float = 72.0 var phase: int = Phase.MINION_WAVES var wave_timer: float = 0.0 @@ -43,6 +51,7 @@ 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 +var movement_velocity: Vector2 = Vector2.ZERO func _ready() -> void: @@ -77,6 +86,7 @@ func _process(delta: float) -> void: if phase == Phase.DEFEATED or not combat_active: return animation_phase += delta + _move_around_fleet(delta) wave_timer -= delta if wave_timer <= 0.0: @@ -94,6 +104,31 @@ func _process(delta: float) -> void: queue_redraw() +func _move_around_fleet(delta: float) -> void: + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + movement_velocity = movement_velocity.move_toward(Vector2.ZERO, + move_acceleration * delta) + return + var from_fleet: Vector2 = global_position - fleet.get_centroid() + if from_fleet.length_squared() < 1.0: + from_fleet = Vector2.UP + var away: Vector2 = from_fleet.normalized() + # Close or open the range while continuously strafing around the fleet. + # Acceleration keeps the large creature drifting rather than snapping when + # the player crosses it, and the speed cap makes its orbit predictable. + var range_error: float = from_fleet.length() - preferred_fleet_range + var radial_speed: float = clampf(range_error * 0.8, -move_speed, move_speed) + var desired_velocity: Vector2 = -away * radial_speed \ + + away.orthogonal() * strafe_speed + if phase == Phase.CORE_EXPOSED: + desired_velocity *= 0.7 + desired_velocity = desired_velocity.limit_length(move_speed) + movement_velocity = movement_velocity.move_toward(desired_velocity, + move_acceleration * delta) + global_position += movement_velocity * delta + + func spawn_wave() -> void: if not combat_active or phase == Phase.CORE_EXPOSED or phase == Phase.DEFEATED: return @@ -303,10 +338,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) + var core_color := EXPOSED_IRIS_COLOR if core_exposed else ARMOURED_IRIS_COLOR NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 31.0, 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) + EXPOSED_PUPIL_COLOR if core_exposed else ARMOURED_PUPIL_COLOR, + 1.0, dark) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) diff --git a/surface/BossSurfaceProfile.tres b/surface/BossSurfaceProfile.tres index 24b8cda..993508a 100644 --- a/surface/BossSurfaceProfile.tres +++ b/surface/BossSurfaceProfile.tres @@ -13,6 +13,7 @@ editor_visible = false show_capture_status = false background_shader = ExtResource("2") background_field_texture = ExtResource("4") +background_cell_size = 90.0 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 cee1408..5869385 100644 --- a/surface/PlanetSurfaceProfile.gd +++ b/surface/PlanetSurfaceProfile.gd @@ -21,6 +21,9 @@ extends Resource # 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 +# World-space size of one background field cell. Smaller values reveal more +# of the authored field at once without changing the gameplay camera zoom. +@export_range(40.0, 440.0, 1.0) var background_cell_size: float = 220.0 @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 d507567..83bd0f0 100644 --- a/surface/SurfaceBackground.gd +++ b/surface/SurfaceBackground.gd @@ -70,6 +70,7 @@ func apply_profile(profile: Resource) -> void: if profile.background_shader != null: material.shader = profile.background_shader _provided_field_texture = profile.background_field_texture + material.set_shader_parameter("cell_size", profile.background_cell_size) material.set_shader_parameter("base_color", base_color) material.set_shader_parameter("crater_color", crater_color) material.set_shader_parameter("highlight_color", highlight_color) diff --git a/tests/test_boss_planet.gd b/tests/test_boss_planet.gd index 2e052b7..caf8387 100644 --- a/tests/test_boss_planet.gd +++ b/tests/test_boss_planet.gd @@ -71,6 +71,8 @@ func _process(delta: float) -> bool: 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 float(composed_background.get_shader_parameter("cell_size")) >= 120.0: + return _fail("boss backdrop still magnifies too small a part of its authored field") 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 @@ -143,6 +145,10 @@ func _process(delta: float) -> bool: if not encounter.encounter_active or not boss.combat_active \ or state.encounter_locks_movement: return _fail("the reveal did not start combat and return fleet control") + var boss_position_before_move: Vector2 = boss.global_position + boss._process(0.5) + if boss.global_position.distance_to(boss_position_before_move) < 10.0: + return _fail("the active boss remained stationary") print("off-screen signal led into a roar, shake, health reveal and player control") boss.wave_timer = 999.0 boss.laser_timer = 999.0 @@ -156,6 +162,9 @@ func _process(delta: float) -> bool: for eye in boss.eyes: if eye.opened or eye.is_in_group(COMBAT_TARGET.GROUP): return _fail("a tentacle eye starts open and attackable") + if boss.ARMOURED_IRIS_COLOR.is_equal_approx(boss.eyes[0].IRIS_COLOR) \ + or boss.ARMOURED_PUPIL_COLOR.is_equal_approx(boss.eyes[0].PUPIL_COLOR): + return _fail("the central eye still shares the tentacle-eye palette") # --- D. larger, varied, one-in-three waves -------------------- boss.spawn_wave() @@ -280,8 +289,8 @@ 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 boss.laser_interval > 2.5 or boss.laser_charge_time > 0.75: + return _fail("boss lasers are not using the faster 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")