diff --git a/audio/Sfx.gd b/audio/Sfx.gd index 18317b9..6da2c4b 100644 --- a/audio/Sfx.gd +++ b/audio/Sfx.gd @@ -27,6 +27,7 @@ const SHIP_DEATH: String = "ship_death" const ENEMY_DEATH: String = "enemy_death" const BUILDING_DEATH: String = "building_death" const DIALOG: String = "dialog" +const BOSS_SCREAM: String = "boss_scream" const SOUNDS: Dictionary = { SHOT: "res://audio/sfx_shot.wav", @@ -34,6 +35,7 @@ const SOUNDS: Dictionary = { ENEMY_DEATH: "res://audio/sfx_ship_death.wav", BUILDING_DEATH: "res://audio/sfx_building_death.wav", DIALOG: "res://audio/sfx_dialog.wav", + BOSS_SCREAM: "res://audio/sfx_boss_scream.wav", } # Per-sound level. A shot is fired constantly and a death happens once, so @@ -44,6 +46,7 @@ const LEVELS: Dictionary = { ENEMY_DEATH: -23.0, BUILDING_DEATH: -23.0, DIALOG: -19.0, + BOSS_SCREAM: -13.0, } # Shots vary a little in pitch so a stream of them does not turn into one diff --git a/audio/make_sfx.py b/audio/make_sfx.py index e9d0929..957cea1 100644 --- a/audio/make_sfx.py +++ b/audio/make_sfx.py @@ -183,9 +183,32 @@ def explosion(seconds, sub_from, sub_to, roar_seed, debris_level, drive): return [_saturate(v, drive) * _attack(i, 0.001) for i, v in enumerate(mixed)] +def boss_scream(seconds=1.65): + """A huge synthetic creature waking: a rough falling throat tone with + an unstable upper rasp. Long enough to carry the reveal shake, without + borrowing a human/animal recording whose licence would need tracking.""" + n = int(RATE * seconds) + throat = [0.0] * n + phase = 0.0 + for i in range(n): + t = i / n + hz = 118.0 * (52.0 / 118.0) ** (t ** 0.72) + hz *= 1.0 + 0.07 * math.sin(t * math.pi * 17.0) + phase += 2.0 * math.pi * hz / RATE + envelope = min(1.0, t / 0.06) * (1.0 - t) ** 0.75 + throat[i] = (math.sin(phase) * 0.75 + math.sin(phase * 2.03) * 0.32 + + math.sin(phase * 3.97) * 0.16) * envelope + rasp_noise = _noise(n, 189, lambda t: 0.34 * (1.0 - t) + 0.025) + rasp = [value * min(1.0, i / (RATE * 0.025)) * _decay(i, n, 1.15) * 0.68 + for i, value in enumerate(rasp_noise)] + mixed = _mix(throat, rasp) + return [_saturate(value, 3.2) for value in mixed] + + if __name__ == "__main__": _write("sfx_shot", shot()) _write("sfx_dialog", dialog()) + _write("sfx_boss_scream", boss_scream()) # A vessel coming apart: bright, fast, with real low end under it. _write("sfx_ship_death", explosion(1.45, 260.0, 38.0, 5, 0.16, 2.6)) # A structure: longer, deeper, and it keeps coming down afterwards. diff --git a/audio/sfx_boss_scream.wav b/audio/sfx_boss_scream.wav new file mode 100644 index 0000000..dce2cfd Binary files /dev/null and b/audio/sfx_boss_scream.wav differ diff --git a/audio/sfx_boss_scream.wav.import b/audio/sfx_boss_scream.wav.import new file mode 100644 index 0000000..b92f13e --- /dev/null +++ b/audio/sfx_boss_scream.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://disto7xb3s8gx" +path="res://.godot/imported/sfx_boss_scream.wav-bf123df4e039686a30aabf3e3bb9dd12.sample" + +[deps] + +source_file="res://audio/sfx_boss_scream.wav" +dest_files=["res://.godot/imported/sfx_boss_scream.wav-bf123df4e039686a30aabf3e3bb9dd12.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/boss/BossEncounter.gd b/boss/BossEncounter.gd index 1552a65..65b3ef7 100644 --- a/boss/BossEncounter.gd +++ b/boss/BossEncounter.gd @@ -2,11 +2,24 @@ class_name BossEncounter extends Node2D @onready var boss: Node2D = $Boss +@onready var fleet: Node2D = get_node("../../Fleet") +@onready var camera: Camera2D = get_node("../../Camera2D") +@onready var health_caption: Label = $EncounterUI/HealthCaption @onready var status_label: Label = $EncounterUI/StatusLabel @onready var hint_label: Label = $EncounterUI/HintLabel @onready var health_bar: ProgressBar = $EncounterUI/HealthBar +@onready var indicator: Control = $EncounterUI/BossIndicator +@onready var indicator_label: Label = $EncounterUI/IndicatorLabel @onready var game_state: Node = get_node("/root/GameState") +const REVEAL_SECONDS: float = 1.15 +const REVEAL_SHAKE: float = 28.0 +const APPROACH_STANDOFF: float = 850.0 + +var reveal_started: bool = false +var reveal_left: float = 0.0 +var encounter_active: bool = false + func _ready() -> void: # One persistent objective lets boss victory flow through the same conquest @@ -18,6 +31,10 @@ func _ready() -> void: boss.suppress_as_defeated() health_bar.max_value = boss.max_health() health_bar.value = 0 + health_caption.visible = false + health_bar.visible = false + indicator.visible = false + indicator_label.visible = false status_label.text = "THE WARDEN IS DEAD" hint_label.text = "" return @@ -26,15 +43,108 @@ func _ready() -> void: boss.health_changed.connect(_on_boss_health_changed) health_bar.max_value = boss.max_health() health_bar.value = boss.current_health() + boss.set_combat_active(false) + _set_combat_ui_visible(false) + indicator.visible = true + indicator_label.visible = true + game_state.encounter_locks_movement = true + # The encounter introduces itself in the world: the fleet is drawn toward + # the signal until the Warden reaches the edge of view, then control is + # returned after the roar. No dialog or separate scene hides the approach. + _begin_approach.call_deferred() _refresh_status() +func _begin_approach() -> void: + # PlanetSurfaceController composes this encounter before it spawns the + # restored fleet. Waiting one call guarantees there are ships for the + # scripted move to steer. + if not reveal_started and not fleet.ships.is_empty(): + fleet.move_to(boss.global_position + Vector2(0.0, APPROACH_STANDOFF)) + + +func _exit_tree() -> void: + if game_state != null: + game_state.encounter_locks_movement = false + if camera != null: + camera.offset = Vector2.ZERO + + +func _process(delta: float) -> void: + if boss.phase == boss.Phase.DEFEATED: + return + if not reveal_started: + _update_indicator() + if _boss_reaches_view(): + begin_reveal() + return + if encounter_active: + return + reveal_left -= delta + var strength: float = REVEAL_SHAKE * clampf(reveal_left / REVEAL_SECONDS, 0.0, 1.0) + camera.offset = Vector2(randf_range(-strength, strength), randf_range(-strength, strength)) + if reveal_left <= 0.0: + camera.offset = Vector2.ZERO + encounter_active = true + boss.set_combat_active(true) + game_state.encounter_locks_movement = false + + +func begin_reveal() -> void: + if reveal_started or boss.phase == boss.Phase.DEFEATED: + return + reveal_started = true + reveal_left = REVEAL_SECONDS + fleet.stop() + indicator.visible = false + indicator_label.visible = false + _set_combat_ui_visible(true) + status_label.text = "THE WARDEN AWAKENS" + hint_label.text = "" + Sfx.play(get_tree(), Sfx.BOSS_SCREAM) + + +func _set_combat_ui_visible(shown: bool) -> void: + for control in [health_caption, health_bar, status_label, hint_label]: + control.visible = shown + + +func _boss_reaches_view() -> bool: + # World-space math is immediate. The viewport canvas transform trails a + # Camera2D assignment until the render server updates, which made a fast + # approach able to cross the threshold one frame before the detector saw + # it. Including the tentacle reach makes the reveal begin when the first + # part of the creature actually touches the view. + var half_view: Vector2 = get_viewport_rect().size * 0.5 / camera.zoom + var extent: float = boss.TENTACLE_LENGTH * boss.scale.x + var separation: Vector2 = (boss.global_position - camera.global_position).abs() + return separation.x <= half_view.x + extent \ + and separation.y <= half_view.y + extent + + +func _update_indicator() -> void: + var viewport_size: Vector2 = get_viewport_rect().size + var screen_at: Vector2 = get_viewport().get_canvas_transform() * boss.global_position + var from_center: Vector2 = screen_at - viewport_size * 0.5 + if from_center == Vector2.ZERO: + from_center = Vector2.UP + var half: Vector2 = indicator.size * 0.5 + indicator.position = Vector2( + clampf(screen_at.x - half.x, 24.0, viewport_size.x - indicator.size.x - 24.0), + clampf(screen_at.y - half.y, 170.0, viewport_size.y - indicator.size.y - 24.0)) + indicator.rotation = from_center.angle() + PI / 2.0 + indicator_label.position.x = clampf(indicator.position.x - 90.0, + 24.0, viewport_size.x - indicator_label.size.x - 24.0) + indicator_label.position.y = indicator.position.y + indicator.size.y + 8.0 + + func _refresh_status() -> void: - var phase_name: String = "PHASE II · LASER STORM" \ - if boss.phase == boss.Phase.LASER_STORM else "PHASE I · HATCHLINGS" + var phase_name: String = "PHASE III · EXPOSED CORE" if boss.phase == boss.Phase.CORE_EXPOSED \ + else ("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" + hint_label.text = "GOLDEN HATCHLINGS APPEAR EVERY THIRD WAVE · BREAK THE EYES, THEN THE CORE" func _on_boss_phase_changed(_phase: int) -> void: @@ -49,6 +159,8 @@ func _on_boss_health_changed(current: int, maximum: int) -> void: func _on_boss_defeated() -> void: + game_state.encounter_locks_movement = false + camera.offset = Vector2.ZERO game_state.mark_point_captured(game_state.planet_id, 0) health_bar.value = 0 status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED" diff --git a/boss/BossEncounter.tscn b/boss/BossEncounter.tscn index cb7a750..a31662c 100644 --- a/boss/BossEncounter.tscn +++ b/boss/BossEncounter.tscn @@ -1,7 +1,8 @@ -[gd_scene load_steps=5 format=3] +[gd_scene load_steps=6 format=3] [ext_resource type="Script" path="res://boss/BossEncounter.gd" id="1"] [ext_resource type="PackedScene" path="res://boss/OctopusBoss.tscn" id="2"] +[ext_resource type="Script" path="res://ui/IconButton.gd" id="icon_button"] [sub_resource type="StyleBoxFlat" id="StyleBox_health_background"] bg_color = Color(0.08, 0.005, 0.055, 0.92) @@ -35,9 +36,9 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -520.0 -offset_top = 280.0 +offset_top = 166.0 offset_right = 520.0 -offset_bottom = 310.0 +offset_bottom = 196.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 @@ -50,9 +51,9 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -520.0 -offset_top = 312.0 +offset_top = 198.0 offset_right = 520.0 -offset_bottom = 344.0 +offset_bottom = 230.0 theme_override_styles/background = SubResource("StyleBox_health_background") theme_override_styles/fill = SubResource("StyleBox_health_fill") max_value = 18.0 @@ -61,10 +62,12 @@ show_percentage = false [node name="StatusLabel" type="Label" parent="EncounterUI"] mouse_filter = 2 -offset_left = 20.0 -offset_top = 354.0 -offset_right = 700.0 -offset_bottom = 396.0 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -600.0 +offset_top = 240.0 +offset_right = 600.0 +offset_bottom = 282.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 @@ -74,13 +77,35 @@ horizontal_alignment = 1 [node name="HintLabel" type="Label" parent="EncounterUI"] mouse_filter = 2 -offset_left = 20.0 -offset_top = 396.0 -offset_right = 700.0 -offset_bottom = 436.0 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -600.0 +offset_top = 282.0 +offset_right = 600.0 +offset_bottom = 322.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 +theme_override_font_sizes/font_size = 20 text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES" horizontal_alignment = 1 + +[node name="BossIndicator" type="Control" parent="EncounterUI"] +mouse_filter = 2 +offset_right = 112.0 +offset_bottom = 112.0 +script = ExtResource("icon_button") +icon = 4 +glow_color = Color(1, 0.32, 0.66, 1) +round_frame = true + +[node name="IndicatorLabel" type="Label" parent="EncounterUI"] +mouse_filter = 2 +offset_right = 292.0 +offset_bottom = 42.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 = 22 +text = "HOSTILE SIGNAL" +horizontal_alignment = 1 diff --git a/boss/BossEye.gd b/boss/BossEye.gd index a15a2bd..738d3ae 100644 --- a/boss/BossEye.gd +++ b/boss/BossEye.gd @@ -6,9 +6,9 @@ signal health_changed(current: int, maximum: int) const COMBAT_TARGET = preload("res://core/CombatTarget.gd") -@export var max_hp: int = 3 +@export var max_hp: int = 5 -var hp: int = 3 +var hp: int = 5 var opened: bool = false var phase: float = 0.0 var redraw_left: float = 0.0 diff --git a/boss/BossMinion.gd b/boss/BossMinion.gd index 3323527..8276d4a 100644 --- a/boss/BossMinion.gd +++ b/boss/BossMinion.gd @@ -9,9 +9,16 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd") @export var max_hp: int = 5 @export var speed: float = 245.0 @export var acceleration: float = 520.0 +@export var expulsion_speed: float = 520.0 +@export var dash_speed: float = 760.0 var boss: Node2D const REDRAW_INTERVAL: float = 1.0 / 12.0 +const EXPULSION_SECONDS: float = 0.85 +const BRAKE_SECONDS: float = 0.42 +const DASH_SECONDS: float = 0.48 + +enum Motion { EXPELLED, CHASING, BRAKING, DASHING } var hp: int = 5 var velocity: Vector2 = Vector2.ZERO @@ -19,6 +26,11 @@ var phase: float = 0.0 var dead: bool = false var redraw_left: float = 0.0 var fleet: Node2D = null +var motion: int = Motion.CHASING +var motion_left: float = 0.0 +var dash_cooldown: float = 0.0 +var dash_direction: Vector2 = Vector2.ZERO +var uses_dash_pattern: bool = false func _ready() -> void: @@ -31,25 +43,71 @@ func _ready() -> void: queue_redraw() +# The boss throws hatchlings clear of its now much larger silhouette before +# they acquire the fleet. Every third hatchling also gets the stop-and-lunge +# rhythm requested in #189; the others remain steady pursuers so a wave is +# varied rather than uniformly twitchy. +func expel(direction: Vector2, dashes: bool) -> void: + var outward: Vector2 = direction.normalized() + uses_dash_pattern = dashes + motion = Motion.EXPELLED + motion_left = EXPULSION_SECONDS * randf_range(0.8, 1.2) + velocity = outward * expulsion_speed * randf_range(0.85, 1.15) + dash_cooldown = randf_range(1.2, 2.8) + + func _physics_process(delta: float) -> void: phase += delta 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: - 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) + _update_motion(delta) + global_position += velocity * delta + 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() +func _update_motion(delta: float) -> void: + if motion == Motion.EXPELLED: + motion_left -= delta + if motion_left <= 0.0: + motion = Motion.CHASING + return + if fleet == null or fleet.ships.is_empty(): + velocity = velocity.move_toward(Vector2.ZERO, acceleration * delta) + return + var to_fleet: Vector2 = fleet.get_centroid() - global_position + if motion == Motion.BRAKING: + motion_left -= delta + velocity = velocity.move_toward(Vector2.ZERO, acceleration * 2.2 * delta) + if motion_left <= 0.0: + motion = Motion.DASHING + motion_left = DASH_SECONDS + dash_direction = to_fleet.normalized().rotated(randf_range(-0.42, 0.42)) + return + if motion == Motion.DASHING: + motion_left -= delta + velocity = velocity.move_toward(dash_direction * dash_speed, + acceleration * 4.0 * delta) + if motion_left <= 0.0: + motion = Motion.CHASING + dash_cooldown = randf_range(1.8, 3.8) + return + if uses_dash_pattern: + dash_cooldown -= delta + if dash_cooldown <= 0.0: + motion = Motion.BRAKING + motion_left = BRAKE_SECONDS + return + if to_fleet.length() > 1.0: + velocity = velocity.move_toward(to_fleet.normalized() * speed, + acceleration * delta) + + func take_damage(amount: int) -> bool: if dead: return false diff --git a/boss/OctopusBoss.gd b/boss/OctopusBoss.gd index 0f72931..c26f60b 100644 --- a/boss/OctopusBoss.gd +++ b/boss/OctopusBoss.gd @@ -9,19 +9,22 @@ 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") +const COMBAT_TARGET = preload("res://core/CombatTarget.gd") -enum Phase { MINION_WAVES, LASER_STORM, DEFEATED } +enum Phase { MINION_WAVES, LASER_STORM, CORE_EXPOSED, DEFEATED } 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 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 @export var first_wave_delay: float = 1.5 @export var wave_interval: float = 6.0 -@export var minions_per_wave: int = 5 +@export var minions_per_wave: int = 7 @export var laser_interval: float = 3.5 @export var laser_charge_time: float = 0.85 @@ -36,7 +39,10 @@ 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 +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 func _ready() -> void: @@ -68,7 +74,7 @@ func eye_position(index: int) -> Vector2: func _process(delta: float) -> void: - if phase == Phase.DEFEATED: + if phase == Phase.DEFEATED or not combat_active: return animation_phase += delta @@ -89,16 +95,25 @@ func _process(delta: float) -> void: func spawn_wave() -> void: + if not combat_active or phase == Phase.CORE_EXPOSED or phase == Phase.DEFEATED: + return 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 _prune_invalid(spawned_minions) - var wanted: int = maxi(3, + var wanted: int = maxi(5, 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 + # One useful return every third ACTUAL wave. The first contains one so the + # mechanic teaches itself immediately; the two red-only waves afterwards + # make the gold target an event rather than permanent scenery. + var marked_slot: int = wave_index % GOLDEN_WAVE_PERIOD + if marked_slot != 0: + marked_slot = -1 + else: + marked_slot = wave_index % count for i in range(count): var minion: Area2D = MINION_SCENE.instantiate() minion.marked = i == marked_slot @@ -108,8 +123,10 @@ func spawn_wave() -> void: 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 + var launch_direction := Vector2.from_angle(angle) + var launch_radius: float = BODY_RADIUS * scale.x + 60.0 + minion.global_position = global_position + launch_direction * launch_radius + minion.expel(launch_direction, (i + wave_index) % 3 == 0) spawned_minions.append(minion) wave_index += 1 @@ -140,7 +157,7 @@ func _on_eye_destroyed(_index: int) -> void: eyes_destroyed += 1 health_changed.emit(current_health(), max_health()) if eyes_destroyed >= EYE_COUNT: - _defeat() + _expose_core() return if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES: phase = Phase.LASER_STORM @@ -154,7 +171,7 @@ func _on_eye_health_changed(_current: int, _maximum: int) -> void: func current_health() -> int: - var total: int = 0 + var total: int = core_health for eye in eyes: if is_instance_valid(eye): total += maxi(int(eye.hp), 0) @@ -187,6 +204,36 @@ func fire_laser_volley() -> void: beam.launch(global_position, toward.rotated(float(spread))) +func set_combat_active(active: bool) -> void: + combat_active = active + if active: + wave_timer = first_wave_delay + laser_timer = laser_interval + + +func _expose_core() -> void: + if core_exposed or phase == Phase.DEFEATED: + return + core_exposed = true + phase = Phase.CORE_EXPOSED + COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY) + $CoreCollision.set_deferred("disabled", false) + phase_changed.emit(phase) + queue_redraw() + + +func take_damage(amount: int) -> bool: + if not core_exposed or phase == Phase.DEFEATED: + return false + core_health = maxi(core_health - maxi(amount, 1), 0) + health_changed.emit(current_health(), max_health()) + if core_health > 0: + queue_redraw() + return false + _defeat() + return true + + func _forget_minion(minion: Node) -> void: spawned_minions.erase(minion) @@ -205,6 +252,9 @@ func _defeat() -> void: if phase == Phase.DEFEATED: return phase = Phase.DEFEATED + core_health = 0 + remove_from_group(COMBAT_TARGET.GROUP) + $CoreCollision.set_deferred("disabled", true) for minion in spawned_minions: if is_instance_valid(minion): minion.retire() @@ -220,6 +270,8 @@ func _defeat() -> void: func suppress_as_defeated() -> void: phase = Phase.DEFEATED + core_health = 0 + combat_active = false visible = false set_process(false) @@ -251,8 +303,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) 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) + 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) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) diff --git a/boss/OctopusBoss.tscn b/boss/OctopusBoss.tscn index 9279ac1..4a4a0eb 100644 --- a/boss/OctopusBoss.tscn +++ b/boss/OctopusBoss.tscn @@ -1,9 +1,16 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=3 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) +[sub_resource type="CircleShape2D" id="CircleShape_core"] +radius = 76.0 + +[node name="Boss" type="Area2D"] +position = Vector2(0, -3000) +scale = Vector2(2.7, 2.7) z_index = 2 script = ExtResource("1") + +[node name="CoreCollision" type="CollisionShape2D" parent="."] +disabled = true +shape = SubResource("CircleShape_core") diff --git a/controllers/GameController.gd b/controllers/GameController.gd index e018b60..ae222c6 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -298,7 +298,7 @@ func _process(delta: float) -> void: # #124: the breach drives. One check here covers the stick, the keyboard # and the released-again path together - three separate gates would be # three chances for one of them to keep steering. - if GameState.intro_locks_movement: + if GameState.intro_locks_movement or GameState.encounter_locks_movement: keyboard_steering = false else: _apply_steering() diff --git a/core/GameState.gd b/core/GameState.gd index f456287..161083a 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -47,6 +47,10 @@ var catalog_faults: PackedStringArray = PackedStringArray() # set, so the fleet goes where the script sends it rather than fighting the # player for the last few seconds of the prologue. var intro_locks_movement: bool = false +# Short in-world reveals (currently the boss arrival) use their own lock so +# they do not pretend the prologue is active merely to steer the fleet for a +# few seconds. +var encounter_locks_movement: bool = false # #123: the prologue's convoy is bigger than any fleet the player can field # yet - it is not their fleet, it is the one they are about to lose. An # explicit override rather than a fake doctrine, so nothing about the @@ -751,6 +755,7 @@ func reset_progression() -> void: func reset_new_game() -> void: intro_active = true intro_locks_movement = false + encounter_locks_movement = false intro_fleet_size = 0 has_return_position = false surviving_ship_count = STARTING_SHIP_COUNT diff --git a/tests/_boss_approach_preview.gd b/tests/_boss_approach_preview.gd new file mode 100644 index 0000000..4da1768 --- /dev/null +++ b/tests/_boss_approach_preview.gd @@ -0,0 +1,25 @@ +extends Node2D + +# Stable portrait view of #189's pre-fight state: the landed fleet is still +# below the unseen Warden, combat HUD is absent, and only the edge indicator +# gives away what is pulling the fleet upward. + + +func _ready() -> void: + GameState.reset_new_game() + GameState.intro_active = false + GameState.reset_quests(false) + GameState.planet_name = "Many-Eyed Deep" + GameState.planet_id = "preview:boss:approach" + GameState.planet_definition_id = "boss" + GameState.surviving_ship_count = 3 + GameState.surviving_vessel_types = PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_GAS_BOMBER, + GameState.VESSEL_LASER, + ]) + var surface: Node = load("res://surface/PlanetSurface.tscn").instantiate() + add_child(surface) + surface.get_node("FadeOverlay/FadeRect").visible = false + # Four screenshot frames are nowhere near enough for the automatic + # approach to cover 950 world units, so the signal remains off-screen. diff --git a/tests/_boss_approach_preview.gd.uid b/tests/_boss_approach_preview.gd.uid new file mode 100644 index 0000000..38d242c --- /dev/null +++ b/tests/_boss_approach_preview.gd.uid @@ -0,0 +1 @@ +uid://c50xlb8b4u84r diff --git a/tests/_boss_approach_preview.tscn b/tests/_boss_approach_preview.tscn new file mode 100644 index 0000000..c7c8ce7 --- /dev/null +++ b/tests/_boss_approach_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_boss_approach_preview.gd" id="1"] + +[node name="BossApproachPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_boss_planet_preview.gd b/tests/_boss_planet_preview.gd index 8a77480..206f6a1 100644 --- a/tests/_boss_planet_preview.gd +++ b/tests/_boss_planet_preview.gd @@ -33,6 +33,11 @@ func _process(_delta: float) -> void: laid_out = true var encounter: Node = surface.get_node("SurfaceFeatures/BossEncounter") var boss: Node2D = encounter.get_node("Boss") + encounter.begin_reveal() + encounter._process(encounter.REVEAL_SECONDS + 0.1) + var fleet: Node2D = surface.get_node("Fleet") + fleet.teleport_to(boss.global_position + Vector2(0.0, 780.0)) + surface.get_node("Camera2D").global_position = fleet.get_centroid() boss.wave_timer = 999.0 boss.laser_timer = 999.0 for i in range(3): @@ -44,6 +49,8 @@ func _process(_delta: float) -> void: boss.open_next_eye() boss.spawn_wave() for minion in get_tree().get_nodes_in_group("boss_minions"): + # Let the authored expulsion fan complete before freezing the gallery. + minion._physics_process(0.65) minion.speed = 0.0 minion.acceleration = 0.0 boss.laser_charge_time = 999.0 diff --git a/tests/test_boss_planet.gd b/tests/test_boss_planet.gd index f52ad43..9280466 100644 --- a/tests/test_boss_planet.gd +++ b/tests/test_boss_planet.gd @@ -8,12 +8,13 @@ extends SceneTree # scripted profile is excluded from the map editor # B. landing composes BossEncounter instead of a PlanetSetup and does not # mark the world conquered for free -# C. phase one spawns an octopus wave with exactly one marked hatchling -# D. killing the marked hatchling sends a visible return to the boss and +# C. the off-screen approach locks movement and reveals with its own cue +# D. waves are larger, expelled outward, varied, and golden only 1/3 times +# E. killing the marked hatchling sends a visible return to the boss and # opens an attackable tentacle eye -# E. three destroyed eyes start the three-lane laser phase -# F. destroying every eye defeats the boss, persists conquest, and a second -# landing does not resurrect it +# F. three destroyed eyes start the three-lane laser phase +# G. eyes expose a separate core; destroying it persists conquest, and a +# second landing does not resurrect the boss const COMBAT_TARGET = preload("res://core/CombatTarget.gd") const BOSS_SCRIPT = preload("res://boss/OctopusBoss.gd") @@ -110,6 +111,37 @@ func _process(delta: float) -> bool: if surface.get_node("UI/CaptureLabel").visible: return _fail("ordinary capture-point status is visible during the boss encounter") boss = encounter.get_node("Boss") + if not (boss is Area2D) or boss.get_node_or_null("CoreCollision") == null: + return _fail("the boss core has no projectile collision target") + if boss.position.y > -2400.0 or boss.scale.x < 2.7: + return _fail("the boss is not far above the landing point and at least twice its old size") + if encounter.health_bar.visible or encounter.status_label.visible: + return _fail("boss combat UI is visible before the boss enters view") + if not encounter.indicator.visible or not encounter.indicator_label.visible: + return _fail("the off-screen boss has no directional border indicator") + if not state.encounter_locks_movement or boss.combat_active: + return _fail("the approach did not take temporary control or already started combat") + # Put the camera where the automatic approach eventually carries it; + # the encounter itself must detect the boss crossing into view. + surface.get_node("Camera2D").global_position = boss.global_position + Vector2(0.0, 1000.0) + encounter._process(0.0) + if not encounter.reveal_started: + return _fail("bringing the boss into view did not trigger its reveal") + if not encounter.health_bar.visible or encounter.indicator.visible: + return _fail("the reveal did not exchange the indicator for the health UI") + var scream_heard: bool = false + var sfx_pool: Node = root.get_node_or_null(Sfx.NODE_NAME) + if sfx_pool != null: + for voice in sfx_pool.get_children(): + if voice.stream != null and voice.stream.resource_path.ends_with("sfx_boss_scream.wav"): + scream_heard = true + if not scream_heard: + return _fail("the on-screen reveal played no boss scream") + encounter._process(encounter.REVEAL_SECONDS + 0.1) + 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") + print("off-screen signal led into a roar, shake, health reveal and player control") boss.wave_timer = 999.0 boss.laser_timer = 999.0 var health_bar: ProgressBar = encounter.get_node("EncounterUI/HealthBar") @@ -123,14 +155,17 @@ func _process(delta: float) -> bool: if eye.opened or eye.is_in_group(COMBAT_TARGET.GROUP): return _fail("a tentacle eye starts open and attackable") - # --- C. phase-one wave ---------------------------------------- + # --- D. larger, varied, one-in-three waves -------------------- boss.spawn_wave() var minions: Array = get_nodes_in_group("boss_minions") if minions.size() != boss.minions_per_wave: return _fail("phase one spawned %d hatchlings, expected %d" \ % [minions.size(), boss.minions_per_wave]) var marked: Node = null + var dashers: int = 0 for minion in minions: + if minion.uses_dash_pattern: + dashers += 1 if minion.marked: if marked != null: return _fail("one wave contains more than one marked hatchling") @@ -139,6 +174,25 @@ func _process(delta: float) -> bool: minion.take_damage(999) if marked == null: return _fail("phase-one wave has no marked hatchling") + if minions.size() < 7 or dashers < 2: + return _fail("the boss wave is not larger and behaviorally mixed") + var launch_distance: float = marked.global_position.distance_to(boss.global_position) + var launch_velocity: Vector2 = marked.velocity + if launch_distance <= boss.BODY_RADIUS * boss.scale.x \ + or launch_velocity.dot(marked.global_position - boss.global_position) <= 0.0: + return _fail("a hatchling was not expelled outward from the boss") + marked._physics_process(0.4) + if marked.global_position.distance_to(boss.global_position) <= launch_distance + 100.0: + return _fail("the outward launch did not spread a hatchling before it targeted the fleet") + marked._physics_process(1.0) + marked.dash_cooldown = 0.0 + marked._physics_process(0.01) + if marked.motion != marked.Motion.BRAKING: + return _fail("a dash-pattern hatchling never stops before lunging") + marked._physics_process(marked.BRAKE_SECONDS + 0.01) + marked._physics_process(0.2) + if marked.motion != marked.Motion.DASHING or marked.velocity.length() <= marked.speed: + return _fail("a stopped hatchling did not accelerate into its random dash") if marked.max_hp < 5: return _fail("boss hatchlings still have only %d health" % marked.max_hp) marked.take_damage(1) @@ -151,7 +205,20 @@ func _process(delta: float) -> bool: if returns.size() != 1: return _fail("marked death launched %d returns, expected one" % returns.size()) returns[0].speed = 4000.0 - print("phase one spawned a wave; the marked kill returned toward the boss") + for expected_marked in [0, 0, 1]: + boss.spawn_wave() + var spawned: Array = get_nodes_in_group("boss_minions") + var marked_count: int = 0 + for minion in spawned: + if minion.is_queued_for_deletion(): + continue + if minion.marked: + marked_count += 1 + minion.retire() + if marked_count != expected_marked: + return _fail("golden cadence produced %d marked hatchlings, expected %d" \ + % [marked_count, expected_marked]) + print("larger waves launch outward with dashers; only one of three is golden") stage = 1 elapsed = 0.0 return false @@ -180,6 +247,8 @@ func _process(delta: float) -> bool: eye.take_damage(999) if boss.eyes_destroyed != 1: return _fail("destroying the first eye did not damage the boss") + if boss.max_health() - boss.current_health() >= boss.max_health() / boss.EYE_COUNT: + return _fail("one eye still removes a full sixth of the total encounter health") print("return opened a tentacle eye; the eye accepted damage and broke") _open_return() stage = 2 @@ -224,15 +293,6 @@ func _process(delta: float) -> bool: 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") - var phase_two_marked: int = 0 - for minion in phase_two_minions: - if minion.marked: - phase_two_marked += 1 - 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: @@ -242,18 +302,29 @@ func _process(delta: float) -> bool: minion.retire() print("three broken eyes started a telegraphed three-lane laser phase") - # --- F. all eyes -> persistent victory ------------------------ + # --- G. all eyes -> exposed core -> persistent victory -------- for i in range(3, boss.EYE_COUNT): boss.eyes[i].open_eye() boss.eyes[i].take_damage(999) + if boss.phase != BOSS_SCRIPT.Phase.CORE_EXPOSED or not boss.visible: + return _fail("breaking every eye did not expose a still-living core") + if state.is_planet_conquered(state.planet_id): + return _fail("breaking the eyes conquered the planet before the core died") + if not boss.is_in_group(COMBAT_TARGET.GROUP): + return _fail("the exposed core is not an attackable combat target") + var core_before: int = boss.core_health + boss.take_damage(1) + if boss.core_health != core_before - 1 or boss.phase == BOSS_SCRIPT.Phase.DEFEATED: + return _fail("the exposed core did not provide a separate final health pool") + boss.take_damage(999) if boss.phase != BOSS_SCRIPT.Phase.DEFEATED or boss.visible: - return _fail("breaking every eye did not defeat and hide the boss") + return _fail("destroying the core 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") + print("all eyes exposed a durable core; destroying it persisted planet conquest") surface.free() surface = load("res://surface/PlanetSurface.tscn").instantiate()