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") 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, 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 = 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 = 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 var laser_timer: float = 0.0 var wave_index: int = 0 var eyes: Array = [] var eyes_opened: int = 0 var eyes_destroyed: int = 0 var spawned_minions: Array = [] var return_signals: Array = [] var animation_phase: float = 0.0 var redraw_left: float = 0.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 var movement_velocity: Vector2 = Vector2.ZERO func _ready() -> void: add_to_group("bosses") wave_timer = first_wave_delay laser_timer = laser_interval _build_eyes() queue_redraw() func _build_eyes() -> void: for i in range(EYE_COUNT): var eye: Area2D = EYE_SCENE.instantiate() eye.position = eye_position(i) eye.rotation = eye.position.angle() add_child(eye) eye.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: var angle: float = -PI / 2.0 + TAU * float(index) / float(EYE_COUNT) # Alternating reach keeps the silhouette organic without randomising the # actual weak-point layout between attempts. var reach: float = TENTACLE_LENGTH * (0.88 if index % 2 == 0 else 1.0) return Vector2.from_angle(angle) * reach func _process(delta: float) -> void: if phase == Phase.DEFEATED or not combat_active: return animation_phase += delta _move_around_fleet(delta) wave_timer -= delta if wave_timer <= 0.0: spawn_wave() wave_timer = wave_interval * (1.25 if phase == Phase.LASER_STORM else 1.0) if phase == Phase.LASER_STORM: laser_timer -= delta if laser_timer <= 0.0: fire_laser_volley() laser_timer = laser_interval redraw_left -= delta if redraw_left <= 0.0: redraw_left = REDRAW_INTERVAL 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 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(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 # 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 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. 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 func accept_marked_kill(from: Vector2) -> void: if phase == Phase.DEFEATED or get_parent() == null: return var returning: Node2D = RETURN_SCENE.instantiate() returning.target = self get_parent().add_child(returning) returning.tree_exiting.connect(_forget_return.bind(returning)) returning.global_position = from return_signals.append(returning) func open_next_eye() -> bool: if phase == Phase.DEFEATED: return false for eye in eyes: if is_instance_valid(eye) and not bool(eye.opened): eye.open_eye() eyes_opened += 1 return true return false func _on_eye_destroyed(_index: int) -> void: eyes_destroyed += 1 health_changed.emit(current_health(), max_health()) if eyes_destroyed >= EYE_COUNT: _expose_core() return if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES: phase = Phase.LASER_STORM 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 = core_health 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 var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") if fleet == null or fleet.ships.is_empty(): return var toward: Vector2 = fleet.get_centroid() - global_position if toward.length() < 1.0: return # Three locked, dodgeable lanes form the second phase's distinct pattern. for spread in [-0.17, 0.0, 0.17]: var beam: Node2D = LASER_SCENE.instantiate() beam.charge_time = laser_charge_time beam.infinite_range = true beam.beam_half_width = 24.0 beam.damage = 1 beam.color = Color(1.0, 0.12, 0.58) get_tree().root.add_child(beam) beam.add_to_group("boss_lasers") beam.launch(global_position, toward.rotated(float(spread))) func 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) 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 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() for returning in return_signals: if is_instance_valid(returning): returning.queue_free() EnemyExplosionVFX.spawn(get_tree().root, global_position) Sfx.play(get_tree(), Sfx.ENEMY_DEATH) visible = false phase_changed.emit(phase) defeated.emit() func suppress_as_defeated() -> void: phase = Phase.DEFEATED core_health = 0 combat_active = false visible = false set_process(false) func _draw() -> void: if phase == Phase.DEFEATED: return var flesh := Color(0.95, 0.18, 0.58) var phase_color := Color(1.0, 0.1, 0.32) if phase == Phase.LASER_STORM \ else flesh var dark := Color(0.075, 0.004, 0.05) for i in range(EYE_COUNT): var end: Vector2 = eye_position(i) var angle: float = end.angle() var side := Vector2.from_angle(angle + PI / 2.0) var points := PackedVector2Array() for step in range(9): var t: float = float(step) / 8.0 var sway: float = sin(animation_phase * 1.7 + float(i) * 1.4 + t * PI * 2.0) points.append(end * t + side * sway * 18.0 * sin(t * PI)) NeonDraw.draw_glowing_path(self, points, phase_color, 0.8, 0.8, dark) var body := PackedVector2Array() for i in range(28): var angle: float = TAU * float(i) / 28.0 var pulse: float = 1.0 + 0.045 * sin(animation_phase * 2.4 + float(i) * 1.9) body.append(Vector2.from_angle(angle) * BODY_RADIUS * pulse) NeonDraw.draw_glowing_polygon(self, body, phase_color, 0.92, dark) # The central eye watches but is armoured; only tentacle eyes opened by a # returning marked minion join the combat-target group. draw_set_transform(Vector2.ZERO, 0.0, Vector2(0.72, 1.0)) 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, EXPOSED_PUPIL_COLOR if core_exposed else ARMOURED_PUPIL_COLOR, 1.0, dark) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)