487 lines
14 KiB
GDScript
487 lines
14 KiB
GDScript
extends EnemyBase
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Relay — Floor 2 boss
|
|
# Teleports around the room, fires burst projectiles, spawns Drifters.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
enum Phase { ONE, TWO, CLIMAX }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
const ROOM_HW := 480.0
|
|
const ROOM_HH := 270.0
|
|
const WALL_T := 32.0
|
|
const WALL_MARGIN := 60.0
|
|
|
|
const TELEPORT_CD_P1_MIN := 6.0
|
|
const TELEPORT_CD_P1_MAX := 8.0
|
|
const TELEPORT_CD_P2_MIN := 4.0
|
|
const TELEPORT_CD_P2_MAX := 5.0
|
|
|
|
const FADE_OUT_TIME := 0.1
|
|
const TRANSIT_TIME := 0.4
|
|
|
|
const BURST_COUNT_P1 := 8
|
|
const BURST_COUNT_P2 := 12
|
|
const BURST_SPEED := 150.0
|
|
const PROJ_SIZE := 12.0
|
|
|
|
const HOMING_COUNT_P1 := 4
|
|
const HOMING_COUNT_P2 := 6
|
|
const HOMING_SPEED := 140.0
|
|
const HOMING_TURN_SPEED := 1.8 # radians per second
|
|
const HOMING_DELAY := 0.3 # delay after radial burst before homing fires
|
|
const HOMING_LIFETIME := 7.0 # seconds before homing projectiles expire
|
|
|
|
const DRIFTER_SPAWN_MIN := 2
|
|
const DRIFTER_SPAWN_MAX := 4
|
|
const DRIFTER_COUNT_P1_MIN := 2
|
|
const DRIFTER_COUNT_P1_MAX := 3
|
|
const DRIFTER_COUNT_P2_MIN := 3
|
|
const DRIFTER_COUNT_P2_MAX := 4
|
|
const DRIFTER_OFFSET_MIN := 40.0
|
|
const DRIFTER_OFFSET_MAX := 80.0
|
|
|
|
const FREEZE_DURATION := 1.5
|
|
|
|
const BURST_PROJ_DAMAGE := 1.0
|
|
const HOMING_PROJ_DAMAGE := 1.0
|
|
|
|
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
|
|
const BODY_PART_PICKUP = preload("res://scenes/upgrades/body_part_pickup.tscn")
|
|
const RUN_ITEM_PICKUP = preload("res://scenes/items/run_item_pickup.tscn")
|
|
|
|
const ALL_PART_PATHS: Array[String] = [
|
|
"res://data/body_parts/torso_reinforced_chassis.tres",
|
|
"res://data/body_parts/torso_lightweight_frame.tres",
|
|
"res://data/body_parts/left_arm_scatter_emitter.tres",
|
|
"res://data/body_parts/left_arm_shield_projector.tres",
|
|
"res://data/body_parts/right_arm_heavy_emitter.tres",
|
|
"res://data/body_parts/right_arm_rapid_emitter.tres",
|
|
]
|
|
|
|
const ALL_ITEM_PATHS: Array[String] = [
|
|
"res://data/run_items/coolant_leak.tres",
|
|
"res://data/run_items/overclock_module.tres",
|
|
"res://data/run_items/scrap_magnet.tres",
|
|
"res://data/run_items/memory_spike.tres",
|
|
"res://data/run_items/rust_coat.tres",
|
|
"res://data/run_items/static_discharge.tres",
|
|
"res://data/run_items/fragmented_map.tres",
|
|
"res://data/run_items/plating_shard.tres",
|
|
"res://data/run_items/range_booster.tres",
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
var _phase : Phase = Phase.ONE
|
|
var _room_center : Vector2
|
|
var _visual : Node2D
|
|
var _contact_area : Area2D
|
|
|
|
var _teleport_timer : float = 3.0 # initial delay before first teleport
|
|
var _transit_timer : float = 0.0
|
|
var _fade_timer : float = 0.0
|
|
|
|
var _teleport_count : int = 0
|
|
var _next_drifter_threshold : int = 0
|
|
|
|
var _burst_projs : Array[Node2D] = []
|
|
var _homing_projs : Array[Node2D] = []
|
|
var _homing_delay : float = 0.0
|
|
|
|
var _climax_timer : float = 0.0
|
|
var _blink_accum : float = 0.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
func _ready() -> void:
|
|
super._ready()
|
|
max_health = 200.0
|
|
current_health = max_health
|
|
contact_damage = 1.0
|
|
contact_cooldown = 0.8
|
|
|
|
_visual = $Visual
|
|
_contact_area = $ContactArea
|
|
|
|
_room_center = global_position
|
|
_roll_drifter_threshold()
|
|
_teleport_timer = 2.5 # initial delay before first teleport
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
_tick_spawn_delay(delta)
|
|
_find_player()
|
|
if _phase == Phase.CLIMAX:
|
|
_tick_climax(delta)
|
|
return
|
|
if not is_active():
|
|
return
|
|
|
|
_tick_teleport(delta)
|
|
_tick_burst_projs(delta)
|
|
_tick_homing_delay(delta)
|
|
_tick_homing_projs(delta)
|
|
|
|
velocity = Vector2.ZERO
|
|
move_and_slide()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Teleport cycle (fully timer-based, no tween callbacks)
|
|
# ---------------------------------------------------------------------------
|
|
enum TeleportState { IDLE, FADING_OUT, IN_TRANSIT }
|
|
var _tp_state: int = TeleportState.IDLE
|
|
|
|
func _tick_teleport(delta: float) -> void:
|
|
match _tp_state:
|
|
TeleportState.IDLE:
|
|
_teleport_timer -= delta
|
|
if _teleport_timer <= 0.0:
|
|
_start_fade_out()
|
|
TeleportState.FADING_OUT:
|
|
_fade_timer -= delta
|
|
_visual.modulate.a = _fade_timer / FADE_OUT_TIME
|
|
if _fade_timer <= 0.0:
|
|
_enter_transit()
|
|
TeleportState.IN_TRANSIT:
|
|
_transit_timer -= delta
|
|
if _transit_timer <= 0.0:
|
|
_reappear()
|
|
|
|
func _start_fade_out() -> void:
|
|
AudioManager.play("relay_teleport")
|
|
_tp_state = TeleportState.FADING_OUT
|
|
_fade_timer = FADE_OUT_TIME
|
|
|
|
func _enter_transit() -> void:
|
|
_tp_state = TeleportState.IN_TRANSIT
|
|
_contact_area.monitoring = false
|
|
_contact_area.monitorable = false
|
|
_visual.visible = false
|
|
_visual.modulate.a = 0.0
|
|
_transit_timer = TRANSIT_TIME
|
|
|
|
func _reappear() -> void:
|
|
# Pick random position within room bounds
|
|
var min_x := _room_center.x - ROOM_HW + WALL_T + WALL_MARGIN
|
|
var max_x := _room_center.x + ROOM_HW - WALL_T - WALL_MARGIN
|
|
var min_y := _room_center.y - ROOM_HH + WALL_T + WALL_MARGIN
|
|
var max_y := _room_center.y + ROOM_HH - WALL_T - WALL_MARGIN
|
|
|
|
global_position = Vector2(
|
|
randf_range(min_x, max_x),
|
|
randf_range(min_y, max_y)
|
|
)
|
|
|
|
# Show visual with white flash
|
|
_visual.visible = true
|
|
_visual.modulate = Color(2.0, 2.0, 2.0, 1.0) # bright flash
|
|
_contact_area.monitoring = true
|
|
_contact_area.monitorable = true
|
|
|
|
var tw := create_tween()
|
|
var base_color := Color(0.3, 0.8, 1.0) if _phase == Phase.TWO else Color(1, 1, 1, 1)
|
|
tw.tween_property(_visual, "modulate", base_color, 0.15)
|
|
|
|
_tp_state = TeleportState.IDLE
|
|
_teleport_count += 1
|
|
|
|
# Fire radial burst immediately
|
|
_fire_burst()
|
|
|
|
# Check drifter spawn - skip homing projectiles when spawning drifters
|
|
if _teleport_count >= _next_drifter_threshold:
|
|
_spawn_drifters()
|
|
_teleport_count = 0
|
|
_roll_drifter_threshold()
|
|
else:
|
|
# Fire homing projectiles only when not spawning drifters
|
|
_homing_delay = HOMING_DELAY
|
|
|
|
_roll_teleport_cooldown()
|
|
|
|
func _roll_teleport_cooldown() -> void:
|
|
if _phase == Phase.TWO:
|
|
_teleport_timer = randf_range(TELEPORT_CD_P2_MIN, TELEPORT_CD_P2_MAX)
|
|
else:
|
|
_teleport_timer = randf_range(TELEPORT_CD_P1_MIN, TELEPORT_CD_P1_MAX)
|
|
|
|
func _roll_drifter_threshold() -> void:
|
|
_next_drifter_threshold = randi_range(DRIFTER_SPAWN_MIN, DRIFTER_SPAWN_MAX)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Burst Transmission
|
|
# ---------------------------------------------------------------------------
|
|
func _fire_burst() -> void:
|
|
AudioManager.play("relay_burst")
|
|
var count := BURST_COUNT_P2 if _phase == Phase.TWO else BURST_COUNT_P1
|
|
var angle_step := TAU / float(count)
|
|
|
|
for i in count:
|
|
var angle := angle_step * i
|
|
var dir := Vector2.from_angle(angle)
|
|
_spawn_burst_proj(dir)
|
|
|
|
func _spawn_burst_proj(dir: Vector2) -> void:
|
|
var proj := Node2D.new()
|
|
proj.set_meta("dir", dir)
|
|
|
|
var poly := Polygon2D.new()
|
|
var hs := PROJ_SIZE * 0.5
|
|
poly.polygon = PackedVector2Array([
|
|
Vector2(-hs, -hs), Vector2(hs, -hs),
|
|
Vector2(hs, hs), Vector2(-hs, hs)
|
|
])
|
|
poly.color = Color(1.0, 0.7, 0.2) # yellow-orange
|
|
proj.add_child(poly)
|
|
|
|
var area := Area2D.new()
|
|
area.collision_layer = 0
|
|
area.collision_mask = 2
|
|
var shape := CollisionShape2D.new()
|
|
var circle := CircleShape2D.new()
|
|
circle.radius = hs
|
|
shape.shape = circle
|
|
area.add_child(shape)
|
|
proj.add_child(area)
|
|
area.body_entered.connect(_on_burst_hit_player.bind(proj))
|
|
|
|
get_parent().add_child(proj)
|
|
proj.global_position = global_position # Set AFTER add_child for correct transform
|
|
_burst_projs.append(proj)
|
|
|
|
func _on_burst_hit_player(body: Node2D, proj: Node2D) -> void:
|
|
if body.is_in_group("player") and body.has_method("take_damage"):
|
|
body.take_damage(BURST_PROJ_DAMAGE)
|
|
if is_instance_valid(proj):
|
|
proj.queue_free()
|
|
|
|
func _tick_burst_projs(delta: float) -> void:
|
|
var still_valid: Array[Node2D] = []
|
|
|
|
for proj in _burst_projs:
|
|
if not is_instance_valid(proj):
|
|
continue
|
|
|
|
var dir: Vector2 = proj.get_meta("dir")
|
|
proj.global_position += dir * BURST_SPEED * delta
|
|
|
|
# Check bounds
|
|
var rel := proj.global_position - _room_center
|
|
if absf(rel.x) > ROOM_HW - WALL_T or absf(rel.y) > ROOM_HH - WALL_T:
|
|
proj.queue_free()
|
|
else:
|
|
still_valid.append(proj)
|
|
|
|
_burst_projs = still_valid
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Homing Projectiles
|
|
# ---------------------------------------------------------------------------
|
|
func _tick_homing_delay(delta: float) -> void:
|
|
if _homing_delay <= 0.0:
|
|
return
|
|
_homing_delay -= delta
|
|
if _homing_delay <= 0.0:
|
|
_fire_homing()
|
|
|
|
func _fire_homing() -> void:
|
|
var count := HOMING_COUNT_P2 if _phase == Phase.TWO else HOMING_COUNT_P1
|
|
for i in count:
|
|
var angle := TAU * float(i) / float(count)
|
|
_spawn_homing_proj(angle)
|
|
|
|
func _spawn_homing_proj(start_angle: float) -> void:
|
|
var proj := Node2D.new()
|
|
proj.set_meta("angle", start_angle)
|
|
proj.set_meta("age", 0.0)
|
|
|
|
# Diamond shape visual
|
|
var poly := Polygon2D.new()
|
|
poly.polygon = PackedVector2Array([
|
|
Vector2(0, -8), Vector2(5, 0),
|
|
Vector2(0, 8), Vector2(-5, 0)
|
|
])
|
|
poly.color = Color(0.9, 0.3, 0.9) # purple/magenta
|
|
proj.add_child(poly)
|
|
|
|
var area := Area2D.new()
|
|
area.collision_layer = 0
|
|
area.collision_mask = 2
|
|
var shape := CollisionShape2D.new()
|
|
var circle := CircleShape2D.new()
|
|
circle.radius = 6.0
|
|
shape.shape = circle
|
|
area.add_child(shape)
|
|
proj.add_child(area)
|
|
area.body_entered.connect(_on_homing_hit_player.bind(proj))
|
|
|
|
get_parent().add_child(proj)
|
|
proj.global_position = global_position # Set AFTER add_child
|
|
_homing_projs.append(proj)
|
|
|
|
func _on_homing_hit_player(body: Node2D, proj: Node2D) -> void:
|
|
if body.is_in_group("player") and body.has_method("take_damage"):
|
|
body.take_damage(HOMING_PROJ_DAMAGE)
|
|
if is_instance_valid(proj):
|
|
proj.queue_free()
|
|
|
|
func _tick_homing_projs(delta: float) -> void:
|
|
if _player == null or not is_instance_valid(_player):
|
|
return
|
|
|
|
var still_valid: Array[Node2D] = []
|
|
|
|
for proj in _homing_projs:
|
|
if not is_instance_valid(proj):
|
|
continue
|
|
|
|
# Age check
|
|
var age: float = proj.get_meta("age") + delta
|
|
proj.set_meta("age", age)
|
|
if age >= HOMING_LIFETIME:
|
|
proj.queue_free()
|
|
continue
|
|
|
|
var angle: float = proj.get_meta("angle")
|
|
var to_player := (_player.global_position - proj.global_position).normalized()
|
|
var desired_angle := to_player.angle()
|
|
|
|
# Smoothly turn towards player
|
|
var diff := angle_difference(angle, desired_angle)
|
|
angle += clampf(diff, -HOMING_TURN_SPEED * delta, HOMING_TURN_SPEED * delta)
|
|
proj.set_meta("angle", angle)
|
|
|
|
# Move forward
|
|
var dir := Vector2.from_angle(angle)
|
|
proj.global_position += dir * HOMING_SPEED * delta
|
|
proj.rotation = angle + PI / 2 # Point diamond forward
|
|
|
|
# Check bounds
|
|
var rel := proj.global_position - _room_center
|
|
if absf(rel.x) > ROOM_HW - WALL_T or absf(rel.y) > ROOM_HH - WALL_T:
|
|
proj.queue_free()
|
|
else:
|
|
still_valid.append(proj)
|
|
|
|
_homing_projs = still_valid
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signal Drifters
|
|
# ---------------------------------------------------------------------------
|
|
func _spawn_drifters() -> void:
|
|
var count: int
|
|
if _phase == Phase.TWO:
|
|
count = randi_range(DRIFTER_COUNT_P2_MIN, DRIFTER_COUNT_P2_MAX)
|
|
else:
|
|
count = randi_range(DRIFTER_COUNT_P1_MIN, DRIFTER_COUNT_P1_MAX)
|
|
|
|
for i in count:
|
|
var drifter := DRIFTER_SCENE.instantiate()
|
|
var offset := Vector2.from_angle(randf() * TAU) * randf_range(DRIFTER_OFFSET_MIN, DRIFTER_OFFSET_MAX)
|
|
get_parent().add_child(drifter)
|
|
drifter.global_position = global_position + offset
|
|
# Signal drifters: weaker than normal drifters
|
|
drifter.max_health = drifter.max_health * 0.5
|
|
drifter.current_health = drifter.max_health
|
|
drifter.speed = drifter.speed * 0.7
|
|
drifter.scale = Vector2(0.7, 0.7)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Damage / Phase
|
|
# ---------------------------------------------------------------------------
|
|
func take_damage(amount: float) -> void:
|
|
if _tp_state != TeleportState.IDLE or _phase == Phase.CLIMAX:
|
|
return
|
|
|
|
current_health -= amount
|
|
AudioManager.play("enemy_hit")
|
|
|
|
if current_health <= 5.0 and _phase != Phase.CLIMAX:
|
|
_enter_climax()
|
|
return
|
|
|
|
if _phase == Phase.ONE and current_health <= max_health * 0.4:
|
|
_enter_phase_two()
|
|
|
|
_flash_damage()
|
|
|
|
func _enter_phase_two() -> void:
|
|
_phase = Phase.TWO
|
|
_visual.modulate = Color(0.3, 0.8, 1.0)
|
|
|
|
func _flash_damage() -> void:
|
|
_visual.modulate = Color(1, 0.2, 0.2)
|
|
var tw := create_tween()
|
|
var base := Color(0.3, 0.8, 1.0) if _phase == Phase.TWO else Color(1, 1, 1)
|
|
tw.tween_property(_visual, "modulate", base, 0.12)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Climax / Death
|
|
# ---------------------------------------------------------------------------
|
|
func _enter_climax() -> void:
|
|
AudioManager.play("boss_death")
|
|
_phase = Phase.CLIMAX
|
|
_climax_timer = FREEZE_DURATION
|
|
_blink_accum = 0.0
|
|
velocity = Vector2.ZERO
|
|
|
|
func _tick_climax(delta: float) -> void:
|
|
_climax_timer -= delta
|
|
_blink_accum += delta
|
|
|
|
if fmod(_blink_accum, 0.15) < 0.075:
|
|
_visual.modulate = Color(1, 0.2, 0.2)
|
|
else:
|
|
_visual.modulate = Color(1, 1, 1)
|
|
|
|
if _climax_timer <= 0.0:
|
|
_die()
|
|
|
|
func _die() -> void:
|
|
AudioManager.play("enemy_death")
|
|
|
|
# Cleanup burst projectiles
|
|
for proj in _burst_projs:
|
|
if is_instance_valid(proj):
|
|
proj.queue_free()
|
|
_burst_projs.clear()
|
|
|
|
# Cleanup homing projectiles
|
|
for proj in _homing_projs:
|
|
if is_instance_valid(proj):
|
|
proj.queue_free()
|
|
_homing_projs.clear()
|
|
|
|
# Always spawn item pickup
|
|
_spawn_item_drop()
|
|
# Also spawn body part if player doesn't have all
|
|
_spawn_body_part_drop()
|
|
|
|
EventBus.boss_died.emit()
|
|
queue_free()
|
|
|
|
func _spawn_item_drop() -> void:
|
|
var chosen: String = ALL_ITEM_PATHS[randi() % ALL_ITEM_PATHS.size()]
|
|
var pickup = RUN_ITEM_PICKUP.instantiate()
|
|
pickup.item = load(chosen)
|
|
pickup.global_position = global_position + Vector2(-30, 0)
|
|
get_parent().add_child(pickup)
|
|
|
|
func _spawn_body_part_drop() -> void:
|
|
var unacquired: Array[String] = []
|
|
for path in ALL_PART_PATHS:
|
|
if path not in UpgradeManager.acquired_part_paths:
|
|
unacquired.append(path)
|
|
if unacquired.is_empty():
|
|
return
|
|
var chosen: String = unacquired[randi() % unacquired.size()]
|
|
var pickup = BODY_PART_PICKUP.instantiate()
|
|
pickup.part = load(chosen)
|
|
pickup.global_position = global_position + Vector2(30, 0)
|
|
get_parent().add_child(pickup)
|