From 0fbaf1ee652cc1cdf46e8735c65ee323c8d47918 Mon Sep 17 00:00:00 2001 From: Mike McGhen Date: Mon, 9 Mar 2026 10:58:08 -0400 Subject: [PATCH] Adds Relay Boss --- scenes/enemies/relay.tscn | 38 +++++ scripts/enemies/relay.gd | 305 ++++++++++++++++++++++++++++++++++++++ scripts/rooms/room.gd | 7 +- 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 scenes/enemies/relay.tscn create mode 100644 scripts/enemies/relay.gd diff --git a/scenes/enemies/relay.tscn b/scenes/enemies/relay.tscn new file mode 100644 index 0000000..d2e4cb4 --- /dev/null +++ b/scenes/enemies/relay.tscn @@ -0,0 +1,38 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/enemies/relay.gd" id="1_relay"] + +[sub_resource type="CircleShape2D" id="CircleShape2D_body"] +radius = 24.0 + +[sub_resource type="CircleShape2D" id="CircleShape2D_contact"] +radius = 28.0 + +[node name="Relay" type="CharacterBody2D"] +collision_layer = 8 +collision_mask = 1 +script = ExtResource("1_relay") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("CircleShape2D_body") + +[node name="Visual" type="Node2D" parent="."] + +[node name="Body" type="Polygon2D" parent="Visual"] +color = Color(0.15, 0.55, 0.65, 1) +polygon = PackedVector2Array(0, -30, 18, 0, 0, 30, -18, 0) + +[node name="Antenna" type="Polygon2D" parent="Visual"] +color = Color(0.25, 0.7, 0.8, 1) +polygon = PackedVector2Array(-3, -30, 3, -30, 3, -42, -3, -42) + +[node name="AntennaTop" type="Polygon2D" parent="Visual"] +color = Color(0.9, 0.95, 1.0, 1) +polygon = PackedVector2Array(-5, -42, 5, -42, 5, -46, -5, -46) + +[node name="ContactArea" type="Area2D" parent="."] +collision_layer = 0 +collision_mask = 2 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="ContactArea"] +shape = SubResource("CircleShape2D_contact") diff --git a/scripts/enemies/relay.gd b/scripts/enemies/relay.gd new file mode 100644 index 0000000..796f2a0 --- /dev/null +++ b/scripts/enemies/relay.gd @@ -0,0 +1,305 @@ +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 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 DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn") + +# --------------------------------------------------------------------------- +# 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 _in_transit : bool = false + +var _teleport_count : int = 0 +var _next_drifter_threshold : int = 0 + +var _burst_projs : Array[Node2D] = [] + +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() + _roll_teleport_cooldown() + +func _physics_process(delta: float) -> void: + if _phase == Phase.CLIMAX: + _tick_climax(delta) + return + + _tick_teleport(delta) + _tick_burst_projs(delta) + + velocity = Vector2.ZERO + move_and_slide() + +# --------------------------------------------------------------------------- +# Teleport cycle +# --------------------------------------------------------------------------- +func _tick_teleport(delta: float) -> void: + if _in_transit: + return + + _teleport_timer -= delta + if _teleport_timer <= 0.0: + _start_teleport() + +func _start_teleport() -> void: + _in_transit = true + + # Fade out + var tw := create_tween() + tw.tween_property(_visual, "modulate:a", 0.0, FADE_OUT_TIME) + tw.tween_callback(Callable(self, "_on_fade_out_done")) + +func _on_fade_out_done() -> void: + _contact_area.monitoring = false + _contact_area.monitorable = false + _visual.visible = false + + # Transit delay then reappear + var tw := create_tween() + tw.tween_interval(TRANSIT_TIME) + tw.tween_callback(Callable(self, "_reappear")) + +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) + + _in_transit = false + _teleport_count += 1 + + # Fire burst + _fire_burst() + + # Check drifter spawn + if _teleport_count >= _next_drifter_threshold: + _spawn_drifters() + _teleport_count = 0 + _roll_drifter_threshold() + + _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: + 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) + proj.global_position = global_position + + 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) + _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(1) + 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 + +# --------------------------------------------------------------------------- +# 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 + +# --------------------------------------------------------------------------- +# Damage / Phase +# --------------------------------------------------------------------------- +func take_damage(amount: float) -> void: + if _in_transit or _phase == Phase.CLIMAX: + return + + current_health -= amount + EventBus.boss_health_changed.emit(current_health, max_health) + + 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: + _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: + EventBus.boss_died.emit() + + # Cleanup burst projectiles + for proj in _burst_projs: + if is_instance_valid(proj): + proj.queue_free() + _burst_projs.clear() + + queue_free() diff --git a/scripts/rooms/room.gd b/scripts/rooms/room.gd index 0b12cf0..77af538 100644 --- a/scripts/rooms/room.gd +++ b/scripts/rooms/room.gd @@ -20,6 +20,7 @@ const REPEATER_SCENE = preload("res://scenes/enemies/repeater.tscn") const ANCHOR_SCENE = preload("res://scenes/enemies/anchor.tscn") const SUPERVISOR_SCENE = preload("res://scenes/enemies/supervisor.tscn") const WARDEN_SCENE = preload("res://scenes/enemies/warden.tscn") +const RELAY_SCENE = preload("res://scenes/enemies/relay.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 SHOP_PEDESTAL_SCR = preload("res://scripts/items/shop_item_pedestal.gd") @@ -262,7 +263,11 @@ func _spawn_enemies() -> void: func _spawn_boss() -> void: - var scene = WARDEN_SCENE if RunManager.current_floor == 1 else SUPERVISOR_SCENE + var scene: PackedScene + match RunManager.current_floor: + 1: scene = WARDEN_SCENE + 2: scene = RELAY_SCENE + _: scene = SUPERVISOR_SCENE var boss = scene.instantiate() contents.add_child(boss) boss.global_position = to_global(Vector2.ZERO) # room centre