Files
Station-0/scripts/rooms/room.gd
T
2026-03-09 10:58:08 -04:00

516 lines
18 KiB
GDScript

extends Node2D
class_name Room
signal room_cleared
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const ROOM_W := 960.0
const ROOM_H := 540.0
const WALL_T := 32.0 # wall thickness
const DOOR_W := 80.0 # door opening on horizontal walls
const DOOR_H := 80.0 # door opening on vertical walls
const SPAWN_MARGIN := 120.0 # keep enemies away from walls when spawning
const WALL_COLOR := Color(0.18, 0.18, 0.25)
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
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")
const ALL_PART_PATHS: Array[String] = [
"res://data/body_parts/head_wide_angle_lens.tres",
"res://data/body_parts/head_targeting_spike.tres",
"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/patch_kit.tres",
]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var data: RoomData = null
var _floor: Node = null # reference to floor.gd node
var _enemy_count: int = 0
var _doors: Dictionary = {} # direction -> Area2D trigger
# ---------------------------------------------------------------------------
# Node refs (set by room_base.tscn)
# ---------------------------------------------------------------------------
@onready var floor_poly: Polygon2D = $Floor
@onready var walls_node: Node2D = $Walls
@onready var door_triggers: Node2D = $DoorTriggers
@onready var contents: Node2D = $Contents
# ---------------------------------------------------------------------------
# Setup — called by floor.gd after add_child
# ---------------------------------------------------------------------------
func setup(room_data: RoomData, floor_ref: Node) -> void:
data = room_data
_floor = floor_ref
_apply_floor_color()
_build_walls()
_build_door_triggers()
# ---------------------------------------------------------------------------
# Activate — called on first (and repeat) entry
# ---------------------------------------------------------------------------
func activate() -> void:
EventBus.room_entered.emit(data.id if "id" in data else "")
if data.visited:
_update_door_locks()
return
data.visited = true
match data.type:
RoomData.RoomType.COMBAT:
_spawn_enemies()
RoomData.RoomType.BOSS:
_spawn_boss()
RoomData.RoomType.ITEM:
_spawn_run_item()
data.cleared = true
RoomData.RoomType.SHOP:
_spawn_shop()
data.cleared = true
_:
data.cleared = true
_update_door_locks()
# ---------------------------------------------------------------------------
# Floor colour by room type
# ---------------------------------------------------------------------------
func _apply_floor_color() -> void:
match data.type:
RoomData.RoomType.ITEM: floor_poly.color = Color(0.10, 0.16, 0.10)
RoomData.RoomType.SHOP: floor_poly.color = Color(0.16, 0.12, 0.08)
RoomData.RoomType.BOSS: floor_poly.color = Color(0.20, 0.08, 0.08)
_: floor_poly.color = Color(0.12, 0.12, 0.18)
# ---------------------------------------------------------------------------
# Wall generation
# ---------------------------------------------------------------------------
func _build_walls() -> void:
var hw := ROOM_W / 2.0 # 480
var hh := ROOM_H / 2.0 # 270
var hdw := DOOR_W / 2.0 # 40 — half opening width (top/bottom walls)
var hdh := DOOR_H / 2.0 # 40 — half opening height (left/right walls)
var has_up := data.connections.has("up")
var has_down := data.connections.has("down")
var has_left := data.connections.has("left")
var has_right := data.connections.has("right")
# --- Top wall ---
if has_up:
_make_wall(Rect2(-hw, -hh, hw - hdw, WALL_T)) # left of gap
_make_wall(Rect2( hdw, -hh, hw - hdw, WALL_T)) # right of gap
else:
_make_wall(Rect2(-hw, -hh, ROOM_W, WALL_T))
# --- Bottom wall ---
if has_down:
_make_wall(Rect2(-hw, hh - WALL_T, hw - hdw, WALL_T))
_make_wall(Rect2( hdw, hh - WALL_T, hw - hdw, WALL_T))
else:
_make_wall(Rect2(-hw, hh - WALL_T, ROOM_W, WALL_T))
# Side walls occupy the strip between the horizontal walls
# inner_top = -238, inner_bot = 238, gap spans -40 to +40
var inner_top := -hh + WALL_T # -238
var inner_bot := hh - WALL_T # 238
var top_seg_h := -hdh - inner_top # 198
var bot_seg_h := inner_bot - hdh # 198
var full_side := inner_bot - inner_top # 476
# --- Left wall ---
if has_left:
_make_wall(Rect2(-hw, inner_top, WALL_T, top_seg_h)) # above gap
_make_wall(Rect2(-hw, hdh, WALL_T, bot_seg_h)) # below gap
else:
_make_wall(Rect2(-hw, inner_top, WALL_T, full_side))
# --- Right wall ---
if has_right:
_make_wall(Rect2(hw - WALL_T, inner_top, WALL_T, top_seg_h))
_make_wall(Rect2(hw - WALL_T, hdh, WALL_T, bot_seg_h))
else:
_make_wall(Rect2(hw - WALL_T, inner_top, WALL_T, full_side))
func _make_wall(rect: Rect2) -> void:
var body := StaticBody2D.new()
body.collision_layer = 1
body.collision_mask = 0
body.position = rect.get_center()
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = rect.size
cs.shape = rs
body.add_child(cs)
var poly := Polygon2D.new()
var hx := rect.size.x / 2.0
var hy := rect.size.y / 2.0
poly.polygon = PackedVector2Array([
Vector2(-hx, -hy), Vector2(hx, -hy),
Vector2( hx, hy), Vector2(-hx, hy),
])
poly.color = WALL_COLOR
body.add_child(poly)
walls_node.add_child(body)
# ---------------------------------------------------------------------------
# Door triggers
# ---------------------------------------------------------------------------
func _build_door_triggers() -> void:
var hw := ROOM_W / 2.0 # 480
var hh := ROOM_H / 2.0 # 270
# Triggers sit centered on the wall face at the door opening.
# Slightly oversized so the player doesn't have to pixel-perfectly align.
var configs := {
"up": {"pos": Vector2(0, -hh + WALL_T * 0.5), "size": Vector2(DOOR_W * 0.9, WALL_T * 1.5)},
"down": {"pos": Vector2(0, hh - WALL_T * 0.5), "size": Vector2(DOOR_W * 0.9, WALL_T * 1.5)},
"left": {"pos": Vector2(-hw + WALL_T * 0.5, 0), "size": Vector2(WALL_T * 1.5, DOOR_H * 0.9)},
"right": {"pos": Vector2( hw - WALL_T * 0.5, 0), "size": Vector2(WALL_T * 1.5, DOOR_H * 0.9)},
}
for dir in data.connections.keys():
var cfg = configs[dir]
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2 # player layer
area.name = "Door_" + dir
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = cfg["size"]
cs.shape = rs
area.add_child(cs)
area.position = cfg["pos"]
# Use call_deferred so the transition runs outside the physics callback
area.body_entered.connect(_on_door_entered.bind(dir))
door_triggers.add_child(area)
_doors[dir] = area
func _on_door_entered(body: Node, direction: String) -> void:
if body.is_in_group("player"):
_floor.call_deferred(
"transition_to", data.connections[direction], direction
)
# ---------------------------------------------------------------------------
# Enemy spawning
# ---------------------------------------------------------------------------
# Enemy pool and count scale with floor depth per CONTENT_SPEC
func _get_enemy_pool() -> Array:
match RunManager.current_floor:
1: return [DRIFTER_SCENE, DRIFTER_SCENE, DRIFTER_SCENE, REPEATER_SCENE]
2: return [DRIFTER_SCENE, DRIFTER_SCENE, REPEATER_SCENE, ANCHOR_SCENE]
_: return [DRIFTER_SCENE, REPEATER_SCENE, ANCHOR_SCENE, ANCHOR_SCENE]
func _get_enemy_count() -> int:
match RunManager.current_floor:
1: return randi_range(1, 3)
2: return randi_range(2, 4)
_: return randi_range(2, 3)
func _spawn_enemies() -> void:
# Hazards first — added to contents before enemies so they draw underneath
_spawn_hazards()
var hw := ROOM_W / 2.0 - SPAWN_MARGIN
var hh := ROOM_H / 2.0 - SPAWN_MARGIN
var pool := _get_enemy_pool()
var count := _get_enemy_count()
for i in count:
var scene = pool[randi() % pool.size()]
var enemy = scene.instantiate()
var local_pos := Vector2(randf_range(-hw, hw), randf_range(-hh, hh))
contents.add_child(enemy)
enemy.global_position = to_global(local_pos)
enemy.tree_exited.connect(_on_enemy_died, CONNECT_DEFERRED)
_enemy_count += 1
func _spawn_boss() -> void:
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
boss.tree_exited.connect(_on_enemy_died, CONNECT_DEFERRED)
_enemy_count = 1
func _on_enemy_died() -> void:
_enemy_count -= 1
if _enemy_count <= 0:
data.cleared = true
if data.type == RoomData.RoomType.BOSS:
_spawn_body_part_drop()
_spawn_floor_exit()
room_cleared.emit()
EventBus.room_cleared.emit(data.id if "id" in data else "")
_update_door_locks()
func _spawn_floor_exit() -> void:
var is_final_floor := RunManager.current_floor >= 3
# Visual — bright teal portal offset from body part drop (which is at center)
var poly := Polygon2D.new()
poly.polygon = PackedVector2Array([-35, -50, 35, -50, 35, 50, -35, 50])
poly.color = Color(0.2, 0.85, 0.9)
poly.position = Vector2(120, 0)
contents.add_child(poly)
var lbl := Label.new()
lbl.text = "EXTRACT" if is_final_floor else "NEXT FLOOR"
lbl.position = Vector2(85, -75)
contents.add_child(lbl)
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = Vector2(70, 100)
cs.shape = rs
area.add_child(cs)
area.position = Vector2(120, 0)
area.body_entered.connect(func(body: Node) -> void:
if body.is_in_group("player"):
RunManager.player_health_carry = body.stats.current_health
if RunManager.current_floor >= 3:
RunManager.end_run(true)
else:
RunManager.advance_floor()
get_tree().call_deferred("change_scene_to_file", "res://scenes/run/floor.tscn"))
contents.add_child(area)
func _spawn_body_part_drop() -> void:
# Drop a random part the player hasn't yet acquired
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 # player has every part
var chosen: String = unacquired[randi() % unacquired.size()]
var pickup = BODY_PART_PICKUP.instantiate()
pickup.part = load(chosen)
pickup.position = Vector2.ZERO # center of this room
contents.add_child(pickup)
func _spawn_run_item() -> void:
var chosen: String = ALL_ITEM_PATHS[randi() % ALL_ITEM_PATHS.size()]
var pickup = RUN_ITEM_PICKUP.instantiate()
pickup.item = load(chosen)
pickup.position = Vector2.ZERO
contents.add_child(pickup)
# ---------------------------------------------------------------------------
# Environmental hazards — spawned in combat rooms
# ---------------------------------------------------------------------------
func _spawn_hazards() -> void:
# Oil slicks — 0-2 random puddles in the room interior
var hw := ROOM_W / 2.0 - SPAWN_MARGIN
var hh := ROOM_H / 2.0 - SPAWN_MARGIN
for i in randi_range(0, 2):
_make_oil_slick(Vector2(randf_range(-hw, hw), randf_range(-hh, hh)))
# Exposed wiring — 0-2 sections along walls
for i in randi_range(0, 2):
_make_exposed_wiring()
func _make_oil_slick(pos: Vector2) -> void:
var slick := Area2D.new()
slick.collision_layer = 0
slick.collision_mask = 2 | 8 # player (2) + enemies (8)
slick.position = pos
var radius: float = randf_range(44.0, 68.0)
var cs := CollisionShape2D.new()
var sh := CircleShape2D.new()
sh.radius = radius
cs.shape = sh
slick.add_child(cs)
# Visual — flattened dark ellipse (puddle)
var poly := Polygon2D.new()
var pts := PackedVector2Array()
var segs := 14
for i in segs:
var a := i * TAU / segs
pts.append(Vector2(cos(a) * radius, sin(a) * radius * 0.55))
poly.polygon = pts
poly.color = Color(0.04, 0.07, 0.14, 0.78)
slick.add_child(poly)
slick.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player") and b.has_method("enter_slick"):
b.enter_slick()
elif b.is_in_group("enemies") and b.has_method("apply_slow"):
b.apply_slow(999.0, 0.35))
slick.body_exited.connect(func(b: Node) -> void:
if b.is_in_group("player") and b.has_method("exit_slick"):
b.exit_slick()
elif b.is_in_group("enemies") and b.has_method("apply_slow"):
b.apply_slow(0.0, 1.0)) # clear slow immediately
contents.add_child(slick)
func _make_exposed_wiring() -> void:
var hw := ROOM_W / 2.0 - WALL_T
var hh := ROOM_H / 2.0 - WALL_T
var wire_len := randf_range(80.0, 150.0)
var wire_thick := 14.0
var inset := wire_thick / 2.0 + 2.0
var half := wire_len / 2.0
var hdw := DOOR_W / 2.0 # 40 — half door gap on horiz walls
var hdh := DOOR_H / 2.0 # 40 — half door gap on vert walls
# Build candidates: {wall, min, max} for center position along the wall.
# Door walls get two segments (one each side of gap); non-door walls get one full segment.
var wall_dirs := ["up", "down", "left", "right"]
var candidates : Array = []
for wall_id in 4:
var has_door := data.connections.has(wall_dirs[wall_id])
var is_horiz := wall_id <= 1
var extent := hw if is_horiz else hh
var door_half := hdw if is_horiz else hdh
if has_door:
var a_min := -extent + half; var a_max := -door_half - half
var b_min := door_half + half; var b_max := extent - half
if a_min <= a_max: candidates.append({"wall": wall_id, "min": a_min, "max": a_max})
if b_min <= b_max: candidates.append({"wall": wall_id, "min": b_min, "max": b_max})
else:
var s_min := -extent + half; var s_max := extent - half
if s_min <= s_max: candidates.append({"wall": wall_id, "min": s_min, "max": s_max})
if candidates.is_empty():
return
var chosen: Dictionary = candidates[randi() % candidates.size()]
var wall := chosen["wall"] as int
var along := randf_range(chosen["min"], chosen["max"])
var pos: Vector2
var size: Vector2
match wall:
0: pos = Vector2(along, -hh + inset); size = Vector2(wire_len, wire_thick)
1: pos = Vector2(along, hh - inset); size = Vector2(wire_len, wire_thick)
2: pos = Vector2(-hw + inset, along); size = Vector2(wire_thick, wire_len)
_: pos = Vector2( hw - inset, along); size = Vector2(wire_thick, wire_len)
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2 # player layer
area.position = pos
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = size
cs.shape = rs
area.add_child(cs)
# Visual — bright yellow-orange strip
var poly := Polygon2D.new()
var hx := size.x / 2.0
var hy := size.y / 2.0
poly.polygon = PackedVector2Array([
Vector2(-hx, -hy), Vector2(hx, -hy),
Vector2( hx, hy), Vector2(-hx, hy),
])
poly.color = Color(1.0, 0.85, 0.05, 0.90)
area.add_child(poly)
# Damage on contact — player iframes act as natural cooldown
area.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player") and b.has_method("take_damage"):
b.take_damage(1.0))
contents.add_child(area)
# ---------------------------------------------------------------------------
# Shop spawning — 2 random items + 1 Patch Kit, spaced horizontally
# ---------------------------------------------------------------------------
func _spawn_shop() -> void:
# Item slot paths (exclude Patch Kit from the random pool)
const PATCH_KIT_PATH := "res://data/run_items/patch_kit.tres"
var item_pool: Array[String] = []
for p in ALL_ITEM_PATHS:
if p != PATCH_KIT_PATH:
item_pool.append(p)
item_pool.shuffle()
# Positions: left item, center item, right = Patch Kit
var positions: Array[Vector2] = [
Vector2(-200, 0),
Vector2( 0, 0),
Vector2( 200, 0),
]
var chosen: Array[String] = [
item_pool[0],
item_pool[1],
PATCH_KIT_PATH,
]
var prices: Array[int] = [20, 20, 10]
for i in 3:
var pedestal := Area2D.new()
pedestal.set_script(SHOP_PEDESTAL_SCR)
pedestal.position = positions[i]
pedestal.set("item", load(chosen[i]))
pedestal.set("price", prices[i])
contents.add_child(pedestal)
# ---------------------------------------------------------------------------
# Door locking
# ---------------------------------------------------------------------------
func _update_door_locks() -> void:
var is_combat := data.type == RoomData.RoomType.COMBAT \
or data.type == RoomData.RoomType.BOSS
var locked := data.visited and not data.cleared and is_combat
for area in _doors.values():
(area as Area2D).monitoring = not locked