312 lines
9.4 KiB
GDScript
312 lines
9.4 KiB
GDScript
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
|
|
|
|
@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
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
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 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 := 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,
|
|
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)
|