478 lines
15 KiB
GDScript
478 lines
15 KiB
GDScript
extends EnemyBase
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Warden — Floor 1 boss
|
|
# Orbits the room center. Barrier sweeps + proximity mines.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
enum Phase { ONE, TWO, CLIMAX }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
const ORBIT_RADIUS := 220.0
|
|
const ROOM_HW := 480.0
|
|
const ROOM_HH := 270.0
|
|
const WALL_T := 32.0
|
|
|
|
const ORBIT_SPEED_P1 := 0.55 # rad/sec ≈ 31 deg/sec
|
|
const ORBIT_SPEED_P2 := 0.85 # rad/sec ≈ 49 deg/sec
|
|
|
|
const TELEGRAPH_TIME := 0.8
|
|
const PASS_DELAY := 1.8
|
|
|
|
const SWEEP_SPEED_P1 := 50.0
|
|
const SWEEP_SPEED_P2 := 85.0
|
|
const SWEEP_CD_P1 := 6.5
|
|
const SWEEP_CD_P2 := 4.5
|
|
const PROJ_SPACING := 18.0
|
|
const GAP_W := 90.0
|
|
|
|
const MINE_CD_P1 := 4.0
|
|
const MINE_CD_P2 := 2.5
|
|
const MINE_RADIUS := 18.0
|
|
const MINE_ARM_TIME := 1.0
|
|
const MINE_LIFE_TIME := 3.0
|
|
const MINE_AOE := 70.0
|
|
|
|
const FREEZE_DURATION := 1.5
|
|
|
|
const SWEEP_PROJ_DAMAGE := 1.0
|
|
const MINE_DAMAGE := 1.0
|
|
|
|
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 = Vector2.ZERO
|
|
var _orbit_angle: float = 0.0
|
|
|
|
# Sweep
|
|
var _sweep_cd_timer: float = 3.0 # initial delay before first sweep
|
|
var _telegraphing: bool = false
|
|
var _telegraph_timer: float = 0.0
|
|
var _passes_total: int = 0
|
|
var _passes_fired: int = 0
|
|
var _pass_delay_timer: float = 0.0
|
|
var _sweep_dir: float = 1.0 # 1 = downward, -1 = upward
|
|
var _sweep_projs: Array = [] # [{node: Node2D, vel: Vector2}]
|
|
|
|
# Mines — each entry: {node: Node2D, timer: float, armed: bool, poly: Polygon2D}
|
|
var _mine_cd_timer: float = MINE_CD_P1
|
|
var _mines: Array = []
|
|
|
|
# Climax
|
|
var _freeze_timer: float = 0.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ready
|
|
# ---------------------------------------------------------------------------
|
|
func _ready() -> void:
|
|
max_health = 150.0
|
|
current_health = 150.0
|
|
contact_damage = 1.0
|
|
contact_cooldown = 0.8
|
|
super._ready()
|
|
_room_center = global_position
|
|
_orbit_angle = 0.0
|
|
global_position = _room_center + Vector2(ORBIT_RADIUS, 0.0)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Physics loop
|
|
# ---------------------------------------------------------------------------
|
|
func _physics_process(delta: float) -> void:
|
|
_tick_spawn_delay(delta)
|
|
_find_player()
|
|
_tick_contact(delta)
|
|
_tick_flash(delta)
|
|
_tick_sweep_projs(delta)
|
|
if not is_active():
|
|
return
|
|
|
|
if _phase == Phase.ONE and current_health / max_health <= 0.4:
|
|
_enter_phase_two()
|
|
|
|
match _phase:
|
|
Phase.ONE, Phase.TWO:
|
|
_do_orbit(ORBIT_SPEED_P2 if _phase == Phase.TWO else ORBIT_SPEED_P1, delta)
|
|
_tick_sweep(delta)
|
|
_tick_mines(delta)
|
|
Phase.CLIMAX:
|
|
_do_climax(delta)
|
|
|
|
move_and_slide()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase transitions
|
|
# ---------------------------------------------------------------------------
|
|
func _enter_phase_two() -> void:
|
|
_phase = Phase.TWO
|
|
_sweep_cd_timer = SWEEP_CD_P2 * 0.6 # first P2 sweep sooner
|
|
|
|
func _enter_climax() -> void:
|
|
AudioManager.play("boss_death")
|
|
_phase = Phase.CLIMAX
|
|
velocity = Vector2.ZERO
|
|
_freeze_timer = FREEZE_DURATION
|
|
_telegraphing = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orbit — position set directly; velocity stays zero so move_and_slide is no-op
|
|
# ---------------------------------------------------------------------------
|
|
func _do_orbit(angular_speed: float, delta: float) -> void:
|
|
if _telegraphing:
|
|
velocity = Vector2.ZERO
|
|
return
|
|
_orbit_angle += angular_speed * delta
|
|
global_position = _room_center + Vector2(cos(_orbit_angle), sin(_orbit_angle)) * ORBIT_RADIUS
|
|
velocity = Vector2.ZERO
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Barrier Sweep — multi-pass horizontal wall with gaps
|
|
# ---------------------------------------------------------------------------
|
|
func _tick_sweep(delta: float) -> void:
|
|
# Telegraph: boss pauses + flashes yellow, then fires first pass
|
|
if _telegraphing:
|
|
_telegraph_timer -= delta
|
|
if _telegraph_timer <= 0.0:
|
|
_telegraphing = false
|
|
_passes_fired = 0
|
|
_pass_delay_timer = 0.0
|
|
_fire_sweep_pass()
|
|
return
|
|
|
|
# Waiting between passes
|
|
if _passes_fired > 0 and _passes_fired < _passes_total:
|
|
_pass_delay_timer -= delta
|
|
if _pass_delay_timer <= 0.0:
|
|
_fire_sweep_pass()
|
|
return
|
|
|
|
# All passes done — start cooldown
|
|
if _passes_total > 0 and _passes_fired >= _passes_total:
|
|
_passes_total = 0
|
|
_passes_fired = 0
|
|
_sweep_cd_timer = SWEEP_CD_P2 if _phase == Phase.TWO else SWEEP_CD_P1
|
|
return
|
|
|
|
# Cooldown ticking
|
|
_sweep_cd_timer -= delta
|
|
if _sweep_cd_timer <= 0.0:
|
|
_telegraphing = true
|
|
_telegraph_timer = TELEGRAPH_TIME
|
|
_passes_total = randi_range(2, 4) if _phase == Phase.TWO else randi_range(1, 3)
|
|
_sweep_dir = 1.0 if randf() < 0.5 else -1.0
|
|
|
|
|
|
func _fire_sweep_pass() -> void:
|
|
AudioManager.play("warden_sweep")
|
|
_passes_fired += 1
|
|
_pass_delay_timer = PASS_DELAY
|
|
|
|
var proj_speed := SWEEP_SPEED_P2 if _phase == Phase.TWO else SWEEP_SPEED_P1
|
|
var move_vel := Vector2(0.0, proj_speed * _sweep_dir)
|
|
var inner_left := -ROOM_HW + WALL_T
|
|
var inner_right := ROOM_HW - WALL_T
|
|
var room_w := inner_right - inner_left
|
|
var num_gaps := randi_range(1, 3)
|
|
|
|
# Gap centers evenly spaced
|
|
var gaps: Array[float] = []
|
|
for i in num_gaps:
|
|
gaps.append(inner_left + room_w * float(i + 1) / float(num_gaps + 1))
|
|
|
|
# Start Y: outside the wall at the entering edge
|
|
var start_y: float
|
|
if _sweep_dir > 0.0:
|
|
start_y = _room_center.y - ROOM_HH + WALL_T - 10.0 # top, moving down
|
|
else:
|
|
start_y = _room_center.y + ROOM_HH - WALL_T + 10.0 # bottom, moving up
|
|
|
|
var x := inner_left
|
|
while x <= inner_right:
|
|
var in_gap := false
|
|
for gx in gaps:
|
|
if abs(x - gx) < GAP_W * 0.5:
|
|
in_gap = true
|
|
break
|
|
if not in_gap:
|
|
_spawn_sweep_proj(Vector2(_room_center.x + x, start_y), move_vel)
|
|
x += PROJ_SPACING
|
|
|
|
|
|
func _spawn_sweep_proj(world_pos: Vector2, vel: Vector2) -> void:
|
|
var proj := Node2D.new()
|
|
|
|
var rect := ColorRect.new()
|
|
rect.size = Vector2(14.0, 14.0)
|
|
rect.position = Vector2(-7.0, -7.0)
|
|
rect.color = Color(0.9, 0.7, 0.1, 0.9)
|
|
proj.add_child(rect)
|
|
|
|
var area := Area2D.new()
|
|
area.collision_layer = 0
|
|
area.collision_mask = 2
|
|
var cs := CollisionShape2D.new()
|
|
var rs := RectangleShape2D.new()
|
|
rs.size = Vector2(12.0, 12.0)
|
|
cs.shape = rs
|
|
area.add_child(cs)
|
|
area.body_entered.connect(func(body: Node) -> void:
|
|
if body.is_in_group("player"):
|
|
body.take_damage(SWEEP_PROJ_DAMAGE)
|
|
proj.queue_free())
|
|
proj.add_child(area)
|
|
|
|
get_parent().add_child(proj)
|
|
proj.global_position = world_pos # set after add_child so global_position resolves
|
|
_sweep_projs.append({"node": proj, "vel": vel})
|
|
|
|
|
|
func _tick_sweep_projs(delta: float) -> void:
|
|
var i := _sweep_projs.size() - 1
|
|
while i >= 0:
|
|
var entry: Dictionary = _sweep_projs[i]
|
|
var node = entry["node"] # untyped — prevents crash on freed instance
|
|
if not is_instance_valid(node):
|
|
_sweep_projs.remove_at(i)
|
|
i -= 1
|
|
continue
|
|
node.global_position += entry["vel"] * delta
|
|
if absf(node.global_position.y - _room_center.y) > ROOM_HH + 30.0:
|
|
node.queue_free()
|
|
_sweep_projs.remove_at(i)
|
|
i -= 1
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Timed Mines — drop at orbit position, arm after 1s, explode at 3s with AOE
|
|
# ---------------------------------------------------------------------------
|
|
func _tick_mines(delta: float) -> void:
|
|
# Spawn new mines on cooldown
|
|
_mine_cd_timer -= delta
|
|
if _mine_cd_timer <= 0.0:
|
|
_mine_cd_timer = MINE_CD_P2 if _phase == Phase.TWO else MINE_CD_P1
|
|
_spawn_mine(global_position)
|
|
|
|
# Update each mine's lifecycle
|
|
var i := _mines.size() - 1
|
|
while i >= 0:
|
|
var entry: Dictionary = _mines[i]
|
|
var node: Node2D = entry["node"]
|
|
if not is_instance_valid(node):
|
|
_mines.remove_at(i)
|
|
i -= 1
|
|
continue
|
|
|
|
entry["timer"] += delta
|
|
|
|
# Arm at 1 second — start pulsing red and show AOE ring
|
|
if not entry["armed"] and entry["timer"] >= MINE_ARM_TIME:
|
|
entry["armed"] = true
|
|
var aoe_ring: Polygon2D = entry["aoe_ring"]
|
|
aoe_ring.visible = true
|
|
|
|
# Armed visual pulse (mine core and AOE ring)
|
|
if entry["armed"]:
|
|
var poly: Polygon2D = entry["poly"]
|
|
var aoe_ring: Polygon2D = entry["aoe_ring"]
|
|
var pulse := fmod(entry["timer"], 0.25) < 0.125
|
|
poly.color = Color(1.0, 0.15, 0.1, 0.95) if pulse else Color(0.6, 0.08, 0.05, 0.9)
|
|
aoe_ring.color = Color(1.0, 0.3, 0.1, 0.25) if pulse else Color(1.0, 0.3, 0.1, 0.12)
|
|
|
|
# Explode at 3 seconds
|
|
if entry["timer"] >= MINE_LIFE_TIME:
|
|
_explode_mine(entry)
|
|
_mines.remove_at(i)
|
|
|
|
i -= 1
|
|
|
|
|
|
func _spawn_mine(world_pos: Vector2) -> void:
|
|
AudioManager.play("mine_arm")
|
|
var mine := Node2D.new()
|
|
|
|
# AOE ring visual — shows blast radius when armed (hidden initially)
|
|
var aoe_ring := Polygon2D.new()
|
|
var aoe_pts := PackedVector2Array()
|
|
for j in 24:
|
|
var a := TAU * j / 24.0
|
|
aoe_pts.append(Vector2(cos(a), sin(a)) * MINE_AOE)
|
|
aoe_ring.polygon = aoe_pts
|
|
aoe_ring.color = Color(1.0, 0.3, 0.1, 0.15)
|
|
aoe_ring.visible = false
|
|
mine.add_child(aoe_ring)
|
|
|
|
# Visual — dim dark red polygon circle (not armed yet)
|
|
var poly := Polygon2D.new()
|
|
var pts := PackedVector2Array()
|
|
for j in 12:
|
|
var a := TAU * j / 12.0
|
|
pts.append(Vector2(cos(a), sin(a)) * MINE_RADIUS)
|
|
poly.polygon = pts
|
|
poly.color = Color(0.35, 0.05, 0.05, 0.7) # dim until armed
|
|
mine.add_child(poly)
|
|
|
|
get_parent().add_child(mine)
|
|
mine.global_position = world_pos
|
|
_mines.append({"node": mine, "timer": 0.0, "armed": false, "poly": poly, "aoe_ring": aoe_ring})
|
|
|
|
|
|
func _explode_mine(entry: Dictionary) -> void:
|
|
AudioManager.play("mine_explode")
|
|
var node: Node2D = entry["node"]
|
|
if not is_instance_valid(node):
|
|
return
|
|
|
|
# Check if player is within AOE
|
|
if _player != null and is_instance_valid(_player):
|
|
var dist := node.global_position.distance_to(_player.global_position)
|
|
if dist <= MINE_AOE:
|
|
_player.take_damage(MINE_DAMAGE)
|
|
|
|
# Brief visual flash for explosion (optional: could add particle later)
|
|
var flash := Polygon2D.new()
|
|
var pts := PackedVector2Array()
|
|
for j in 16:
|
|
var a := TAU * j / 16.0
|
|
pts.append(Vector2(cos(a), sin(a)) * MINE_AOE)
|
|
flash.polygon = pts
|
|
flash.color = Color(1.0, 0.4, 0.1, 0.6)
|
|
flash.global_position = node.global_position
|
|
get_parent().add_child(flash)
|
|
|
|
# Remove flash after short delay
|
|
var tw := get_tree().create_tween()
|
|
tw.tween_property(flash, "modulate:a", 0.0, 0.15)
|
|
tw.tween_callback(flash.queue_free)
|
|
|
|
node.queue_free()
|
|
|
|
|
|
func _clear_mines() -> void:
|
|
for entry in _mines:
|
|
if is_instance_valid(entry["node"]):
|
|
entry["node"].queue_free()
|
|
_mines.clear()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Climax — freeze + blink, then die
|
|
# ---------------------------------------------------------------------------
|
|
func _do_climax(delta: float) -> void:
|
|
velocity = Vector2.ZERO
|
|
_freeze_timer -= delta
|
|
var blink := fmod(_freeze_timer, 0.3) < 0.15
|
|
visual.modulate = Color(2.0, 0.3, 0.3) if blink else Color(0.25, 0.08, 0.08)
|
|
if _freeze_timer <= 0.0:
|
|
_die()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Damage / death
|
|
# ---------------------------------------------------------------------------
|
|
func take_damage(amount: float) -> void:
|
|
if _phase == Phase.CLIMAX:
|
|
return # already dying
|
|
current_health -= amount
|
|
_flash_timer = 0.1
|
|
AudioManager.play("enemy_hit")
|
|
if current_health <= 0.0:
|
|
_enter_climax()
|
|
|
|
|
|
func _die() -> void:
|
|
AudioManager.play("enemy_death")
|
|
_clear_mines()
|
|
for entry in _sweep_projs:
|
|
if is_instance_valid(entry["node"]):
|
|
entry["node"].queue_free()
|
|
_sweep_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:
|
|
# Filter out already collected items
|
|
var available: Array[String] = []
|
|
for path in ALL_ITEM_PATHS:
|
|
var dominated := false
|
|
for collected in RunManager.run_items:
|
|
if collected.resource_path == path:
|
|
dominated = true
|
|
break
|
|
if not dominated:
|
|
available.append(path)
|
|
if available.is_empty():
|
|
return
|
|
var chosen: String = available[randi() % available.size()]
|
|
var pickup = RUN_ITEM_PICKUP.instantiate()
|
|
pickup.item = load(chosen)
|
|
var spawn_pos := global_position + Vector2(-30, 0)
|
|
get_parent().call_deferred("add_child", pickup)
|
|
pickup.set_deferred("global_position", spawn_pos)
|
|
|
|
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)
|
|
var spawn_pos := global_position + Vector2(30, 0)
|
|
get_parent().call_deferred("add_child", pickup)
|
|
pickup.set_deferred("global_position", spawn_pos)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Visual — phase-aware tints (overrides EnemyBase._tick_flash)
|
|
# ---------------------------------------------------------------------------
|
|
func _tick_flash(delta: float) -> void:
|
|
if _phase == Phase.CLIMAX:
|
|
return # _do_climax owns the visual
|
|
|
|
# Telegraph: yellow warning pulse (same pattern as Supervisor wind-up)
|
|
if _telegraphing:
|
|
var pulse := fmod(_telegraph_timer, 0.18) < 0.09
|
|
var base := Color(1.0, 0.65, 0.2) if _phase == Phase.TWO else Color(1.0, 1.0, 1.0)
|
|
visual.modulate = Color(2.4, 2.1, 0.3) if pulse else base
|
|
return
|
|
|
|
# Hit flash
|
|
if _flash_timer > 0.0:
|
|
_flash_timer -= delta
|
|
visual.modulate = Color(2.5, 2.5, 2.5)
|
|
return
|
|
|
|
# Idle tint
|
|
visual.modulate = Color(1.0, 0.65, 0.2) if _phase == Phase.TWO else Color(1.0, 1.0, 1.0)
|