Initial commit — Station 0 vertical slice in progress

Includes full design documentation (ARCHITECTURE, DESIGN_NOTES, CONTENT_SPEC, PRODUCTION_NOTES)
and active Godot 4 / GDScript codebase. Core systems built: player, floor generation,
room system, enemy base, body part upgrades, run manager, save system, HUD, EventBus.
This commit is contained in:
Station 0 Dev
2026-03-04 12:33:05 -05:00
commit 342175f4c3
66 changed files with 4683 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
extends Node
# card_id -> count owned
var collection: Dictionary = {}
# Array of { name: String, card_ids: Array[String] }
var decks: Array[Dictionary] = []
func add_card(card_id: String, count: int = 1) -> void:
collection[card_id] = collection.get(card_id, 0) + count
func get_card_count(card_id: String) -> int:
return collection.get(card_id, 0)
func open_pack(pack: Resource) -> void:
# Pack resource defines its card_ids array
if pack == null or not pack.get("card_ids"):
return
for card_id in pack.card_ids:
add_card(card_id)
func create_deck(deck_name: String) -> void:
decks.append({ "name": deck_name, "card_ids": [] })
func get_save_data() -> Dictionary:
return {
"collection": collection.duplicate(),
"decks": decks.duplicate(true),
}
func load_save_data(data: Dictionary) -> void:
collection = data.get("collection", {})
decks = data.get("decks", [])
+136
View File
@@ -0,0 +1,136 @@
extends CharacterBody2D
# ---------------------------------------------------------------------------
# ANCHOR — Structural integrity corruption
# Pathfinds to nearest wall, locks in. Fires slow homing projectiles.
# Does not move once anchored.
# ---------------------------------------------------------------------------
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
enum State { SEEKING, ANCHORED }
var max_health: float = 40.0
var current_health: float = 40.0
var speed: float = 95.0
var contact_damage: float = 0.5
var contact_cooldown: float = 0.8
const SHOT_COOLDOWN := 2.2
# Room interior half-extents (960×540 room, 32px walls, 16px gap buffer)
const INNER_HALF_X := 432.0
const INNER_HALF_Y := 222.0
var _player: Node2D = null
var _state: State = State.SEEKING
var _target_wall: Vector2
var _shot_timer: float = 1.0 # initial fire delay
var _contact_timer: float = 0.0
var _flash_timer: float = 0.0
@onready var visual: Node2D = $Visual
func _ready() -> void:
add_to_group("enemies")
$ContactArea.body_entered.connect(_on_contact_entered)
_compute_wall_target()
# ---------------------------------------------------------------------------
# Find the nearest wall face and set it as the target
# ---------------------------------------------------------------------------
func _compute_wall_target() -> void:
var room_center: Vector2 = (get_parent() as Node2D).global_position
var lp: Vector2 = global_position - room_center
var d_left: float = lp.x - (-INNER_HALF_X)
var d_right: float = INNER_HALF_X - lp.x
var d_up: float = lp.y - (-INNER_HALF_Y)
var d_down: float = INNER_HALF_Y - lp.y
var md: float = minf(minf(d_left, d_right), minf(d_up, d_down))
if md == d_left:
_target_wall = room_center + Vector2(-INNER_HALF_X, lp.y)
elif md == d_right:
_target_wall = room_center + Vector2( INNER_HALF_X, lp.y)
elif md == d_up:
_target_wall = room_center + Vector2(lp.x, -INNER_HALF_Y)
else:
_target_wall = room_center + Vector2(lp.x, INNER_HALF_Y)
func _physics_process(delta: float) -> void:
_find_player()
_tick_contact(delta)
_tick_flash(delta)
match _state:
State.SEEKING:
_do_seek()
State.ANCHORED:
_do_anchored(delta)
move_and_slide()
func _find_player() -> void:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
# ---------------------------------------------------------------------------
# Move toward the nearest wall, then lock in
# ---------------------------------------------------------------------------
func _do_seek() -> void:
var diff := _target_wall - global_position
if diff.length() < 12.0:
velocity = Vector2.ZERO
_state = State.ANCHORED
else:
velocity = diff.normalized() * speed
# ---------------------------------------------------------------------------
# Stationary — fire slow homing projectiles at the player
# ---------------------------------------------------------------------------
func _do_anchored(delta: float) -> void:
velocity = Vector2.ZERO
_shot_timer -= delta
if _shot_timer <= 0.0 and _player != null:
_fire_at_player()
_shot_timer = SHOT_COOLDOWN
func _fire_at_player() -> void:
var dir := (_player.global_position - global_position).normalized()
var proj = ENEMY_PROJECTILE.instantiate()
proj.position = (get_parent() as Node2D).to_local(global_position)
proj.direction = dir
proj.speed = 120.0
proj.damage = 1.0
proj.max_range = 720.0
proj.homing = true
proj.homing_target = _player
get_parent().call_deferred("add_child", proj)
func take_damage(amount: float) -> void:
current_health -= amount
_flash_timer = 0.1
if current_health <= 0.0:
_die()
func _die() -> void:
_spawn_drop()
queue_free()
func _spawn_drop() -> void:
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
orb.position = (get_parent() as Node2D).to_local(global_position)
get_parent().call_deferred("add_child", orb)
func _on_contact_entered(body: Node) -> void:
if body.is_in_group("player") and _contact_timer == 0.0:
body.take_damage(contact_damage)
_contact_timer = contact_cooldown
func _tick_contact(delta: float) -> void:
_contact_timer = maxf(_contact_timer - delta, 0.0)
func _tick_flash(delta: float) -> void:
if _flash_timer > 0.0:
_flash_timer -= delta
visual.modulate = Color(2.5, 2.5, 2.5)
else:
visual.modulate = Color(1.0, 1.0, 1.0)
+17
View File
@@ -0,0 +1,17 @@
extends Area2D
var value: int = 1
func _ready() -> void:
body_entered.connect(_on_body_entered)
# Placeholder visual — gold circle
func _draw() -> void:
draw_circle(Vector2.ZERO, 5.0, Color(1.0, 0.8, 0.1))
draw_arc(Vector2.ZERO, 5.0, 0, TAU, 16, Color(1.0, 1.0, 0.4), 1.0)
func _on_body_entered(body: Node) -> void:
if body.is_in_group("player"):
UpgradeManager.hub_credits += value
EventBus.credits_changed.emit(UpgradeManager.hub_credits)
queue_free()
+79
View File
@@ -0,0 +1,79 @@
extends CharacterBody2D
# ---------------------------------------------------------------------------
# DRIFTER — Locomotion corruption
# Moves in slow arcs and wide curves. Never charges straight. Contact only.
# ---------------------------------------------------------------------------
var max_health: float = 16.0
var current_health: float = 16.0
var speed: float = 80.0
var contact_damage: float = 1.0
var contact_cooldown: float = 0.8
var _player: Node2D = null
var _contact_timer: float = 0.0
var _flash_timer: float = 0.0
var _wobble_time: float = 0.0
var _wobble_phase: float = 0.0 # random start so groups don't sync
@onready var visual: Node2D = $Visual
func _ready() -> void:
add_to_group("enemies")
$ContactArea.body_entered.connect(_on_contact_entered)
_wobble_phase = randf() * TAU
func _physics_process(delta: float) -> void:
_find_player()
_tick_contact(delta)
_tick_flash(delta)
_do_arc_chase(delta)
move_and_slide()
func _find_player() -> void:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
# ---------------------------------------------------------------------------
# Arcing movement — sinusoidal perpendicular drift applied to toward-player dir
# ---------------------------------------------------------------------------
func _do_arc_chase(delta: float) -> void:
if _player == null:
velocity = Vector2.ZERO
return
var toward := (_player.global_position - global_position).normalized()
var perp := toward.rotated(PI * 0.5)
_wobble_time += delta
var wobble := sin(_wobble_time * 1.8 + _wobble_phase) * 0.75
velocity = (toward + perp * wobble).normalized() * speed
func take_damage(amount: float) -> void:
current_health -= amount
_flash_timer = 0.1
if current_health <= 0.0:
_die()
func _die() -> void:
_spawn_drop()
queue_free()
func _spawn_drop() -> void:
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
orb.position = (get_parent() as Node2D).to_local(global_position)
get_parent().call_deferred("add_child", orb)
func _on_contact_entered(body: Node) -> void:
if body.is_in_group("player") and _contact_timer == 0.0:
body.take_damage(contact_damage)
_contact_timer = contact_cooldown
func _tick_contact(delta: float) -> void:
_contact_timer = maxf(_contact_timer - delta, 0.0)
func _tick_flash(delta: float) -> void:
if _flash_timer > 0.0:
_flash_timer -= delta
visual.modulate = Color(2.5, 2.5, 2.5)
else:
visual.modulate = Color(1.0, 1.0, 1.0)
+76
View File
@@ -0,0 +1,76 @@
extends CharacterBody2D
# ---------------------------------------------------------------------------
# Stats — override in derived enemy types
# ---------------------------------------------------------------------------
var max_health: float = 10.0
var current_health: float = 10.0
var speed: float = 60.0
var contact_damage: float = 1.0
var contact_cooldown: float = 0.8
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _contact_timer: float = 0.0
var _flash_timer: float = 0.0
var _player: Node2D = null
@onready var visual: Node2D = $Visual
func _ready() -> void:
add_to_group("enemies")
$ContactArea.body_entered.connect(_on_contact_entered)
func _physics_process(delta: float) -> void:
_contact_timer = maxf(_contact_timer - delta, 0.0)
_tick_flash(delta)
_chase_player()
move_and_slide()
# ---------------------------------------------------------------------------
# AI — simple chaser
# ---------------------------------------------------------------------------
func _chase_player() -> void:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
if _player != null:
velocity = (_player.global_position - global_position).normalized() * speed
else:
velocity = Vector2.ZERO
# ---------------------------------------------------------------------------
# Damage / death
# ---------------------------------------------------------------------------
func take_damage(amount: float) -> void:
current_health -= amount
_flash_timer = 0.1
if current_health <= 0.0:
_die()
func _die() -> void:
_spawn_drop()
queue_free()
func _spawn_drop() -> void:
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
orb.position = (get_parent() as Node2D).to_local(global_position)
get_parent().call_deferred("add_child", orb)
# ---------------------------------------------------------------------------
# Contact damage
# ---------------------------------------------------------------------------
func _on_contact_entered(body: Node) -> void:
if body.is_in_group("player") and _contact_timer == 0.0:
body.take_damage(contact_damage)
_contact_timer = contact_cooldown
# ---------------------------------------------------------------------------
# Hit flash
# ---------------------------------------------------------------------------
func _tick_flash(delta: float) -> void:
if _flash_timer > 0.0:
_flash_timer -= delta
visual.modulate = Color(2.5, 2.5, 2.5)
else:
visual.modulate = Color(1.0, 1.0, 1.0)
+44
View File
@@ -0,0 +1,44 @@
extends Area2D
# ---------------------------------------------------------------------------
# Shared enemy projectile — used by Repeater (linear) and Anchor (homing)
# ---------------------------------------------------------------------------
var direction: Vector2 = Vector2.RIGHT
var speed: float = 200.0
var damage: float = 1.0
var max_range: float = 500.0
# Homing — disabled by default
var homing: bool = false
var homing_target: Node2D = null
const HOMING_STRENGTH := 2.0 # radians/second turn rate
var _distance_traveled: float = 0.0
func _ready() -> void:
rotation = direction.angle()
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
if homing and homing_target != null and is_instance_valid(homing_target):
var to_target := (homing_target.global_position - global_position).normalized()
direction = direction.lerp(to_target, HOMING_STRENGTH * delta).normalized()
rotation = direction.angle()
var move := direction * speed * delta
position += move
_distance_traveled += move.length()
if _distance_traveled >= max_range:
queue_free()
func _on_body_entered(body: Node) -> void:
if body.is_in_group("player"):
body.take_damage(damage)
queue_free()
elif body is StaticBody2D:
queue_free()
func _draw() -> void:
draw_circle(Vector2.ZERO, 4.0, Color(1.0, 0.4, 0.1))
+136
View File
@@ -0,0 +1,136 @@
extends CharacterBody2D
# ---------------------------------------------------------------------------
# REPEATER — Task-loop corruption
# Drifts to last known player position, stops, fires a rotating burst loop.
# Does not re-track the player — stuck completing a subroutine.
# ---------------------------------------------------------------------------
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
enum State { DRIFTING, FIRING }
var max_health: float = 24.0
var current_health: float = 24.0
var speed: float = 60.0
var contact_damage: float = 0.5
var contact_cooldown: float = 0.8
const BURST_SIZE := 3 # shots per burst
const SHOT_INTERVAL := 0.18 # seconds between shots within a burst
const BURST_PAUSE := 1.8 # seconds after burst completes before rotating
const ROTATE_STEP := 0.3491 # 20° in radians
var _player: Node2D = null
var _state: State = State.DRIFTING
var _target_pos: Vector2
var _target_set: bool = false
var _fire_angle: float = 0.0
var _shot_count: int = 0
var _shot_timer: float = 0.0
var _contact_timer: float = 0.0
var _flash_timer: float = 0.0
@onready var visual: Node2D = $Visual
func _ready() -> void:
add_to_group("enemies")
$ContactArea.body_entered.connect(_on_contact_entered)
_fire_angle = randf() * TAU # random initial burst direction
func _physics_process(delta: float) -> void:
_find_player()
_tick_contact(delta)
_tick_flash(delta)
match _state:
State.DRIFTING:
_do_drift()
State.FIRING:
_do_fire(delta)
move_and_slide()
func _find_player() -> void:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
# ---------------------------------------------------------------------------
# Drift toward last known position, then lock and fire
# ---------------------------------------------------------------------------
func _do_drift() -> void:
if _player == null:
velocity = Vector2.ZERO
return
# Capture target position once
if not _target_set:
_target_pos = _player.global_position
_target_set = true
var diff := _target_pos - global_position
if diff.length() < 12.0:
velocity = Vector2.ZERO
# Aim first burst toward player
if _player != null:
_fire_angle = (_player.global_position - global_position).angle()
_state = State.FIRING
_shot_count = 0
_shot_timer = 0.0
else:
velocity = diff.normalized() * speed
# ---------------------------------------------------------------------------
# Stationary burst loop — fire 3, pause, rotate 20° CW, repeat
# ---------------------------------------------------------------------------
func _do_fire(delta: float) -> void:
velocity = Vector2.ZERO
_shot_timer -= delta
if _shot_timer > 0.0:
return
if _shot_count < BURST_SIZE:
_fire_projectile()
_shot_count += 1
_shot_timer = SHOT_INTERVAL
else:
# Burst done — rotate 20° clockwise and reset
_fire_angle += ROTATE_STEP
_shot_count = 0
_shot_timer = BURST_PAUSE
func _fire_projectile() -> void:
var proj = ENEMY_PROJECTILE.instantiate()
proj.position = (get_parent() as Node2D).to_local(global_position)
proj.direction = Vector2.from_angle(_fire_angle)
proj.speed = 280.0
proj.damage = 1.0
proj.max_range = 520.0
get_parent().call_deferred("add_child", proj)
func take_damage(amount: float) -> void:
current_health -= amount
_flash_timer = 0.1
if current_health <= 0.0:
_die()
func _die() -> void:
_spawn_drop()
queue_free()
func _spawn_drop() -> void:
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
orb.position = (get_parent() as Node2D).to_local(global_position)
get_parent().call_deferred("add_child", orb)
func _on_contact_entered(body: Node) -> void:
if body.is_in_group("player") and _contact_timer == 0.0:
body.take_damage(contact_damage)
_contact_timer = contact_cooldown
func _tick_contact(delta: float) -> void:
_contact_timer = maxf(_contact_timer - delta, 0.0)
func _tick_flash(delta: float) -> void:
if _flash_timer > 0.0:
_flash_timer -= delta
visual.modulate = Color(2.5, 2.5, 2.5)
else:
visual.modulate = Color(1.0, 1.0, 1.0)
+339
View File
@@ -0,0 +1,339 @@
extends CharacterBody2D
# ---------------------------------------------------------------------------
# The Supervisor — Phase 1/2/Climax boss
# Corrupted supervisor protocol attempting to decommission the player.
# ---------------------------------------------------------------------------
enum Phase { ONE, TWO, CLIMAX }
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
var max_health: float = 200.0
var current_health: float = 200.0
var contact_damage: float = 1.0
var contact_cooldown: float = 0.8
# ---------------------------------------------------------------------------
# Patrol (Phase 1)
# ---------------------------------------------------------------------------
const PATROL_W := 200.0
const PATROL_H := 100.0
const PATROL_SPEED_P1 := 75.0
# ---------------------------------------------------------------------------
# Chase (Phase 2)
# ---------------------------------------------------------------------------
const PATROL_SPEED_P2 := 105.0
# ---------------------------------------------------------------------------
# Calibration beam
# ---------------------------------------------------------------------------
const BEAM_RANGE := 240.0
const BEAM_HALFANG := 0.50 # ~28.6° half-angle for sensor cone
const BEAM_COOLDOWN_P1 := 3.5
const BEAM_COOLDOWN_P2 := 2.2
const BEAM_DURATION := 0.75 # active damage window (shorter = more dodgeable)
# Wind-up before firing — boss stops and flashes a warning
const WIND_UP_DURATION := 0.65 # seconds of warning before beam fires
# ---------------------------------------------------------------------------
# Climax freeze
# ---------------------------------------------------------------------------
const FREEZE_DURATION := 2.0
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _phase: Phase = Phase.ONE
var _player: Node2D = null
var _patrol_points: Array[Vector2] = []
var _patrol_index: int = 0
var _contact_timer: float = 0.0
var _flash_timer: float = 0.0
# Beam state
var _beam_timer: float = 1.5 # initial delay before first beam check
var _beam_active: bool = false
var _beam_node: Node2D = null
var _beam_dur_timer: float = 0.0
# Wind-up state
var _winding_up: bool = false
var _wind_up_timer: float = 0.0
var _wind_up_dir: Vector2 = Vector2.RIGHT # locked direction when wind-up starts
var _drifter_spawned: bool = false
var _freeze_timer: float = 0.0
@onready var visual: Node2D = $Visual
# ---------------------------------------------------------------------------
# Ready
# ---------------------------------------------------------------------------
func _ready() -> void:
add_to_group("enemies")
$ContactArea.body_entered.connect(_on_contact_entered)
var c := global_position
_patrol_points = [
c + Vector2(-PATROL_W, -PATROL_H),
c + Vector2( PATROL_W, -PATROL_H),
c + Vector2( PATROL_W, PATROL_H),
c + Vector2(-PATROL_W, PATROL_H),
]
# ---------------------------------------------------------------------------
# Physics loop
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
_find_player()
_tick_contact(delta)
_tick_beam(delta)
_tick_flash(delta)
var pct := current_health / max_health
if _phase == Phase.ONE and pct <= 0.55:
_enter_phase_two()
elif _phase == Phase.TWO and pct <= 0.20:
_enter_climax()
match _phase:
Phase.ONE:
# Stop and lock on during wind-up
if _winding_up:
velocity = Vector2.ZERO
else:
_do_patrol(PATROL_SPEED_P1)
Phase.TWO:
if _winding_up:
velocity = Vector2.ZERO
else:
_do_chase(PATROL_SPEED_P2)
_maybe_spawn_drifter()
Phase.CLIMAX:
_do_climax(delta)
move_and_slide()
# ---------------------------------------------------------------------------
# Player lookup
# ---------------------------------------------------------------------------
func _find_player() -> void:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
# ---------------------------------------------------------------------------
# Phase transitions
# ---------------------------------------------------------------------------
func _enter_phase_two() -> void:
_phase = Phase.TWO
_winding_up = false
func _enter_climax() -> void:
_phase = Phase.CLIMAX
velocity = Vector2.ZERO
_freeze_timer = FREEZE_DURATION
_winding_up = false
if is_instance_valid(_beam_node):
_beam_node.queue_free()
_beam_node = null
_beam_active = false
# ---------------------------------------------------------------------------
# Phase 1 — rectangular patrol
# ---------------------------------------------------------------------------
func _do_patrol(spd: float) -> void:
if _patrol_points.is_empty():
return
var target := _patrol_points[_patrol_index]
var diff := target - global_position
if diff.length() < 10.0:
_patrol_index = (_patrol_index + 1) % _patrol_points.size()
else:
velocity = diff.normalized() * spd
# ---------------------------------------------------------------------------
# Phase 2 — direct player chase
# ---------------------------------------------------------------------------
func _do_chase(spd: float) -> void:
if _player == null:
velocity = Vector2.ZERO
return
velocity = (_player.global_position - global_position).normalized() * spd
# ---------------------------------------------------------------------------
# Phase 2 — spawn one Drifter
# ---------------------------------------------------------------------------
func _maybe_spawn_drifter() -> void:
if _drifter_spawned:
return
_drifter_spawned = true
var drifter = DRIFTER_SCENE.instantiate()
drifter.position = (get_parent() as Node2D).to_local(global_position + Vector2(0, 130))
get_parent().call_deferred("add_child", drifter)
# ---------------------------------------------------------------------------
# Climax — frozen distress signal, 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()
# ---------------------------------------------------------------------------
# Calibration beam — with wind-up warning
# ---------------------------------------------------------------------------
func _tick_beam(delta: float) -> void:
if _phase == Phase.CLIMAX:
return
# Active beam — count down duration
if _beam_active:
_beam_dur_timer -= delta
if _beam_dur_timer <= 0.0:
_beam_active = false
if is_instance_valid(_beam_node):
_beam_node.queue_free()
_beam_node = null
return
# Wind-up — boss is stopped and flashing; fires when timer expires
if _winding_up:
_wind_up_timer -= delta
if _wind_up_timer <= 0.0:
_winding_up = false
_fire_beam()
var cd := BEAM_COOLDOWN_P1 if _phase == Phase.ONE else BEAM_COOLDOWN_P2
_beam_timer = cd
return
# Cooldown between beams
if _beam_timer > 0.0:
_beam_timer -= delta
return
# Check sensor cone — start wind-up if player is in range
if _player_in_sensor_cone():
_winding_up = true
_wind_up_timer = WIND_UP_DURATION
# Lock the fire direction to current heading at the moment of detection
_wind_up_dir = velocity.normalized() if velocity.length() > 1.0 else Vector2.RIGHT
else:
_beam_timer = 0.3 # recheck soon
func _player_in_sensor_cone() -> bool:
if _player == null:
return false
var to_player := _player.global_position - global_position
if to_player.length() > BEAM_RANGE:
return false
var forward := velocity.normalized() if velocity.length() > 1.0 else Vector2.RIGHT
return absf(forward.angle_to(to_player.normalized())) < BEAM_HALFANG
func _fire_beam() -> void:
_beam_active = true
_beam_dur_timer = BEAM_DURATION
var forward := _wind_up_dir # use direction locked at wind-up start
var beam_len := BEAM_RANGE
var beam_w := 30.0
var offset := 24.0
var beam := Node2D.new()
beam.position = (get_parent() as Node2D).to_local(global_position)
beam.rotation = forward.angle()
var poly := Polygon2D.new()
poly.polygon = PackedVector2Array([
Vector2(offset, -beam_w * 0.5),
Vector2(offset + beam_len, -beam_w * 0.5),
Vector2(offset + beam_len, beam_w * 0.5),
Vector2(offset, beam_w * 0.5),
])
poly.color = Color(1.0, 0.9, 0.3, 0.60)
beam.add_child(poly)
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = Vector2(beam_len, beam_w)
cs.shape = rs
cs.position = Vector2(offset + beam_len * 0.5, 0.0)
area.add_child(cs)
area.body_entered.connect(func(body: Node) -> void:
if body.is_in_group("player"):
body.take_damage(1.0))
beam.add_child(area)
get_parent().call_deferred("add_child", beam)
_beam_node = beam
# ---------------------------------------------------------------------------
# Damage / death
# ---------------------------------------------------------------------------
func take_damage(amount: float) -> void:
current_health -= amount
_flash_timer = 0.1
EventBus.boss_health_changed.emit(maxf(current_health, 0.0), max_health)
if current_health <= 0.0:
_die()
func _die() -> void:
EventBus.boss_died.emit()
if is_instance_valid(_beam_node):
_beam_node.queue_free()
_spawn_drop()
queue_free()
func _spawn_drop() -> void:
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
orb.position = (get_parent() as Node2D).to_local(global_position)
get_parent().call_deferred("add_child", orb)
# ---------------------------------------------------------------------------
# Contact damage
# ---------------------------------------------------------------------------
func _on_contact_entered(body: Node) -> void:
if body.is_in_group("player") and _contact_timer == 0.0:
body.take_damage(contact_damage)
_contact_timer = contact_cooldown
func _tick_contact(delta: float) -> void:
_contact_timer = maxf(_contact_timer - delta, 0.0)
# ---------------------------------------------------------------------------
# Visual — flash on hit, wind-up warning pulse, phase tints, climax strobe
# ---------------------------------------------------------------------------
func _tick_flash(delta: float) -> void:
if _phase == Phase.CLIMAX:
return # climax handles its own visual in _do_climax
# Wind-up: rapid yellow-white warning pulse — overrides everything else
if _winding_up:
var pulse := fmod(_wind_up_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
# Resting tint per phase
if _phase == Phase.TWO:
visual.modulate = Color(1.0, 0.65, 0.2)
else:
visual.modulate = Color(1.0, 1.0, 1.0)
+57
View File
@@ -0,0 +1,57 @@
extends Node
# ---------------------------------------------------------------------------
# Player / Run lifecycle
# ---------------------------------------------------------------------------
signal player_died
signal player_health_changed(current: float, maximum: float)
signal run_started
signal run_ended(reached_hub: bool)
signal floor_cleared(floor_number: int)
# ---------------------------------------------------------------------------
# Rooms
# ---------------------------------------------------------------------------
signal room_entered(room_id: String)
signal room_cleared(room_id: String)
# ---------------------------------------------------------------------------
# Items & Loot
# ---------------------------------------------------------------------------
signal item_collected(item: Resource)
signal card_pack_found(pack: Resource)
signal body_part_found(part: Resource)
signal body_part_equipped(part: Resource, slot: String)
signal credits_changed(new_total: int)
# ---------------------------------------------------------------------------
# NPCs
# ---------------------------------------------------------------------------
signal npc_found(npc: Resource)
signal npc_saved(npc: Resource)
signal npc_lost(npc: Resource)
# ---------------------------------------------------------------------------
# Lore
# ---------------------------------------------------------------------------
signal lore_discovered(lore_id: String)
# ---------------------------------------------------------------------------
# Card Game
# ---------------------------------------------------------------------------
signal card_game_started(opponent: Resource)
signal card_game_ended(player_won: bool)
# ---------------------------------------------------------------------------
# Boss
# ---------------------------------------------------------------------------
signal boss_health_changed(current: float, maximum: float)
signal boss_died
# ---------------------------------------------------------------------------
# Perspective / View
# ---------------------------------------------------------------------------
signal view_switching_to_topdown
signal view_switching_to_isometric
signal view_switching_to_3d(scene_path: String)
signal view_switching_to_2d
+51
View File
@@ -0,0 +1,51 @@
extends Node
enum GameState {
HUB,
RUN,
CARD_GAME,
CUTSCENE,
PAUSED,
}
var current_state: GameState = GameState.HUB
var _previous_state: GameState = GameState.HUB
func _ready() -> void:
EventBus.run_started.connect(_on_run_started)
EventBus.run_ended.connect(_on_run_ended)
call_deferred("_load_game")
func _load_game() -> void:
SaveManager.load_save()
func change_state(new_state: GameState) -> void:
_previous_state = current_state
current_state = new_state
func pause() -> void:
if current_state != GameState.PAUSED:
change_state(GameState.PAUSED)
get_tree().paused = true
func unpause() -> void:
if current_state == GameState.PAUSED:
change_state(_previous_state)
get_tree().paused = false
func go_to_hub() -> void:
change_state(GameState.HUB)
get_tree().change_scene_to_file("res://scenes/hub/hub.tscn")
func start_run() -> void:
RunManager.start_run(0, [], [])
change_state(GameState.RUN)
get_tree().change_scene_to_file("res://scenes/run/floor.tscn")
func _on_run_started() -> void:
change_state(GameState.RUN)
func _on_run_ended(_survived: bool) -> void:
# Body parts are always kept — save before returning to hub
SaveManager.save()
call_deferred("go_to_hub")
+133
View File
@@ -0,0 +1,133 @@
extends Node2D
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const HUB_ROOM_SCENE = preload("res://scenes/hub/rooms/hub_room_base.tscn")
const ROOM_W := 960.0
const ROOM_H := 540.0
const TRANSITION_TIME := 0.25
const ENTRY_INSET := 60.0
# Fixed hub layout — 6 rooms arranged as:
#
# [Control (0,-1)]
# |
# [Cafe(-1,0)]-[Staging(0,0)]-[Armory(1,0)]
# | |
# [Shop(0,1)]----[Trophy(1,1)]
#
# HubRoomType: STAGING=0, ARMORY=1, CAFETERIA=2, SHOP=3, CONTROL_ROOM=4, TROPHY_ROOM=5
const LAYOUT := [
{"type": 0, "grid": Vector2i( 0, 0)}, # STAGING
{"type": 4, "grid": Vector2i( 0, -1)}, # CONTROL_ROOM
{"type": 2, "grid": Vector2i(-1, 0)}, # CAFETERIA
{"type": 1, "grid": Vector2i( 1, 0)}, # ARMORY
{"type": 3, "grid": Vector2i( 0, 1)}, # SHOP
{"type": 5, "grid": Vector2i( 1, 1)}, # TROPHY_ROOM
]
const DIRS := {
"up": Vector2i( 0, -1),
"down": Vector2i( 0, 1),
"left": Vector2i(-1, 0),
"right": Vector2i( 1, 0),
}
# ---------------------------------------------------------------------------
# Node refs
# ---------------------------------------------------------------------------
@onready var rooms_container: Node2D = $Rooms
@onready var camera: Camera2D = $Camera2D
@onready var player: Node2D = $Player
@onready var armory_ui: CanvasLayer = $ArmoryUI
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _rooms: Dictionary = {} # Vector2i -> HubRoom
var _current_grid: Vector2i
var _transitioning: bool = false
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
func _ready() -> void:
_build_hub()
func _build_hub() -> void:
var connections := _compute_connections()
for entry in LAYOUT:
var grid_pos: Vector2i = entry["grid"]
var room_type: int = entry["type"]
var conn: Dictionary = connections.get(grid_pos, {})
var room_node: HubRoom = HUB_ROOM_SCENE.instantiate()
room_node.position = _grid_to_world(grid_pos)
rooms_container.add_child(room_node)
room_node.setup(room_type, conn, self)
_rooms[grid_pos] = room_node
# Start in Staging Area; player spawns below center so the portal is visible
var staging_world := _grid_to_world(Vector2i(0, 0))
camera.position = staging_world
player.global_position = staging_world + Vector2(0, 80)
_current_grid = Vector2i(0, 0)
func _compute_connections() -> Dictionary:
var grid := {}
for entry in LAYOUT:
grid[entry["grid"]] = true
var result := {}
for entry in LAYOUT:
var pos: Vector2i = entry["grid"]
var conn := {}
for dir_name in DIRS:
var nb: Vector2i = pos + DIRS[dir_name]
if grid.has(nb):
conn[dir_name] = nb
result[pos] = conn
return result
# ---------------------------------------------------------------------------
# Transitions (identical pattern to floor.gd)
# ---------------------------------------------------------------------------
func transition_to(target_grid: Vector2i, from_direction: String) -> void:
if _transitioning:
return
_transitioning = true
_current_grid = target_grid
var new_world := _grid_to_world(target_grid)
player.global_position = new_world + _entry_offset(from_direction)
var tween := create_tween()
tween.tween_property(camera, "position", new_world, TRANSITION_TIME)
tween.tween_callback(func(): _transitioning = false)
func _entry_offset(from_direction: String) -> Vector2:
var i := ENTRY_INSET
match from_direction:
"up": return Vector2(0, ROOM_H / 2.0 - i)
"down": return Vector2(0, -ROOM_H / 2.0 + i)
"left": return Vector2( ROOM_W / 2.0 - i, 0)
"right": return Vector2(-ROOM_W / 2.0 + i, 0)
return Vector2.ZERO
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _grid_to_world(grid_pos: Vector2i) -> Vector2:
return Vector2(grid_pos.x * ROOM_W, grid_pos.y * ROOM_H)
# ---------------------------------------------------------------------------
# Armory
# ---------------------------------------------------------------------------
func open_armory() -> void:
armory_ui.open()
+258
View File
@@ -0,0 +1,258 @@
extends Node2D
class_name HubRoom
# ---------------------------------------------------------------------------
# Room types
# ---------------------------------------------------------------------------
enum HubRoomType {
STAGING = 0,
ARMORY = 1,
CAFETERIA = 2,
SHOP = 3,
CONTROL_ROOM = 4,
TROPHY_ROOM = 5,
}
# ---------------------------------------------------------------------------
# Constants — wall geometry identical to run rooms
# ---------------------------------------------------------------------------
const ROOM_W := 960.0
const ROOM_H := 540.0
const WALL_T := 32.0
const DOOR_W := 80.0
const DOOR_H := 80.0
const WALL_COLOR := Color(0.18, 0.18, 0.25)
const ROOM_NAMES := [
"STAGING AREA", # 0
"ARMORY", # 1
"CAFETERIA", # 2
"SHOP", # 3
"CONTROL ROOM", # 4
"TROPHY ROOM", # 5
]
const FLOOR_COLORS := [
Color(0.14, 0.14, 0.20), # STAGING — neutral dark
Color(0.14, 0.12, 0.10), # ARMORY — warm metal
Color(0.10, 0.14, 0.10), # CAFETERIA — green tint
Color(0.14, 0.10, 0.14), # SHOP — purple tint
Color(0.08, 0.12, 0.18), # CONTROL_ROOM — blue tint
Color(0.16, 0.14, 0.08), # TROPHY_ROOM — warm gold tint
]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _type: int = 0 # HubRoomType value
var _connections: Dictionary = {} # "up"/"down"/"left"/"right" -> Vector2i
var _hub: Node = null
var _doors: Dictionary = {} # direction -> Area2D
# ---------------------------------------------------------------------------
# Node refs
# ---------------------------------------------------------------------------
@onready var floor_poly: Polygon2D = $Floor
@onready var walls_node: Node2D = $Walls
@onready var door_triggers: Node2D = $DoorTriggers
@onready var contents: Node2D = $Contents
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
func setup(room_type: int, connections: Dictionary, hub_ref: Node) -> void:
_type = room_type
_connections = connections
_hub = hub_ref
floor_poly.color = FLOOR_COLORS[_type]
_build_walls()
_build_door_triggers()
_build_room_label()
if _type == HubRoomType.STAGING:
_build_run_portal()
elif _type == HubRoomType.ARMORY:
_build_armory_workbench()
# ---------------------------------------------------------------------------
# Wall generation (same logic as room.gd)
# ---------------------------------------------------------------------------
func _build_walls() -> void:
var hw := ROOM_W / 2.0
var hh := ROOM_H / 2.0
var hdw := DOOR_W / 2.0
var hdh := DOOR_H / 2.0
var has_up := _connections.has("up")
var has_down := _connections.has("down")
var has_left := _connections.has("left")
var has_right := _connections.has("right")
if has_up:
_make_wall(Rect2(-hw, -hh, hw - hdw, WALL_T))
_make_wall(Rect2( hdw, -hh, hw - hdw, WALL_T))
else:
_make_wall(Rect2(-hw, -hh, ROOM_W, WALL_T))
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))
var inner_top := -hh + WALL_T
var inner_bot := hh - WALL_T
var top_seg_h := -hdh - inner_top
var bot_seg_h := inner_bot - hdh
var full_side := inner_bot - inner_top
if has_left:
_make_wall(Rect2(-hw, inner_top, WALL_T, top_seg_h))
_make_wall(Rect2(-hw, hdh, WALL_T, bot_seg_h))
else:
_make_wall(Rect2(-hw, inner_top, WALL_T, full_side))
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
var hh := ROOM_H / 2.0
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 _connections.keys():
var cfg = configs[dir]
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2
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"]
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"):
_hub.call_deferred("transition_to", _connections[direction], direction)
# ---------------------------------------------------------------------------
# Room label
# ---------------------------------------------------------------------------
func _build_room_label() -> void:
var lbl := Label.new()
lbl.text = ROOM_NAMES[_type]
lbl.position = Vector2(-60, -235)
contents.add_child(lbl)
# ---------------------------------------------------------------------------
# Armory workbench
# ---------------------------------------------------------------------------
func _build_armory_workbench() -> void:
# Visual — blue-grey bench
var bench := Polygon2D.new()
bench.polygon = PackedVector2Array([-50, -20, 50, -20, 50, 20, -50, 20])
bench.color = Color(0.30, 0.40, 0.60)
bench.position = Vector2(0, -100)
contents.add_child(bench)
var lbl := Label.new()
lbl.text = "UPGRADE"
lbl.position = Vector2(-30, -140)
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(100, 40)
cs.shape = rs
area.add_child(cs)
area.position = Vector2(0, -100)
area.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player"):
_hub.call_deferred("open_armory"))
contents.add_child(area)
# ---------------------------------------------------------------------------
# Staging Area run portal
# ---------------------------------------------------------------------------
func _build_run_portal() -> void:
# Visual — bright green rectangle
var poly := Polygon2D.new()
poly.polygon = PackedVector2Array([-40, -30, 40, -30, 40, 30, -40, 30])
poly.color = Color(0.2, 0.9, 0.4)
poly.position = Vector2(0, -150)
contents.add_child(poly)
# Label above portal
var lbl := Label.new()
lbl.text = "START RUN"
lbl.position = Vector2(-36, -195)
contents.add_child(lbl)
# Trigger zone
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = Vector2(80, 60)
cs.shape = rs
area.add_child(cs)
area.position = Vector2(0, -150)
area.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player"):
GameManager.call_deferred("start_run"))
contents.add_child(area)
+46
View File
@@ -0,0 +1,46 @@
extends Resource
class_name RunItem
enum Effect {
STAT_MOD = 0, # flat stat modifier
RICOCHET = 1, # projectiles bounce off walls once
SCATTER = 2, # fire N projectiles in a spread
EXPLOSIVE = 3, # projectiles explode on hit
HEAL = 4, # immediate HP restore on pickup, no ongoing effect
COOLANT_LEAK = 5, # leaves a slow zone on taking damage
SCRAP_MAGNET = 6, # scrap tokens auto-attract within magnet_radius
MEMORY_SPIKE = 7, # first projectile per room deals 3x damage
RUST_COAT = 8, # incoming damage reduced by damage_reduction (min 1)
STATIC_DISCHARGE = 9, # AoE burst (discharge_radius) on taking damage
FRAGMENTED_MAP = 10, # reveals one unexplored room on each combat clear
OVERCLOCK = 11, # fire rate x1.4; every 3rd shot deals 0 damage
}
@export var display_name: String = ""
@export var description: String = ""
@export var effect: Effect = Effect.STAT_MOD
# STAT_MOD
@export var stat_key: String = "" # "speed"|"max_health"|"damage"|"fire_rate"|"range"
@export var stat_delta: float = 0.0
# SCATTER
@export var scatter_count: int = 3
@export var scatter_spread_deg: float = 20.0
# EXPLOSIVE
@export var explosion_radius: float = 64.0
@export var explosion_damage: float = 2.5
# HEAL — immediate HP restored on pickup (separate from stat_delta)
@export var heal_on_pickup: float = 0.0
# RUST_COAT
@export var damage_reduction: float = 1.0
# STATIC_DISCHARGE
@export var discharge_radius: float = 64.0
@export var discharge_damage: float = 1.0
# SCRAP_MAGNET
@export var magnet_radius: float = 192.0
+35
View File
@@ -0,0 +1,35 @@
extends Area2D
var item: RunItem = null
func _ready() -> void:
body_entered.connect(_on_body_entered)
# Placeholder visual — cyan diamond
func _draw() -> void:
draw_colored_polygon(PackedVector2Array([
Vector2(0, -14), Vector2(10, 0), Vector2(0, 14), Vector2(-10, 0)
]), Color(0.2, 0.9, 0.85))
draw_arc(Vector2.ZERO, 16.0, 0, TAU, 24, Color(0.5, 1.0, 0.95), 1.5)
func _on_body_entered(body: Node) -> void:
if not body.is_in_group("player") or item == null:
return
RunManager.collect_item(item)
if item.heal_on_pickup > 0.0:
body.stats.heal(item.heal_on_pickup)
body.stats.recalculate()
EventBus.player_health_changed.emit(body.stats.current_health, body.stats.max_health)
_show_pickup_label()
queue_free()
func _show_pickup_label() -> void:
var lbl := Label.new()
lbl.text = item.display_name
lbl.position = global_position + Vector2(-40, -20)
lbl.modulate = Color(0.2, 1.0, 0.85)
get_tree().current_scene.add_child(lbl)
var tween := lbl.create_tween()
tween.tween_property(lbl, "position", lbl.position + Vector2(0, -60), 1.2)
tween.parallel().tween_property(lbl, "modulate:a", 0.0, 1.2)
tween.tween_callback(lbl.queue_free)
+20
View File
@@ -0,0 +1,20 @@
extends Node
# All lore IDs the player has discovered — never lost on death
var discovered: Array[String] = []
func discover(lore_id: String) -> void:
if lore_id not in discovered:
discovered.append(lore_id)
EventBus.lore_discovered.emit(lore_id)
func has_discovered(lore_id: String) -> bool:
return lore_id in discovered
func get_save_data() -> Array:
return discovered.duplicate()
func load_save_data(data: Array) -> void:
discovered.clear()
for entry in data:
discovered.append(str(entry))
+72
View File
@@ -0,0 +1,72 @@
extends Node
enum NPCState {
UNDISCOVERED,
IN_HUB,
ON_RUN,
LOST,
}
# npc_id -> { state: NPCState, resource_path: String }
var _registry: Dictionary = {}
func register_npc(npc_id: String, resource_path: String) -> void:
if npc_id not in _registry:
_registry[npc_id] = {
"state": NPCState.UNDISCOVERED,
"resource_path": resource_path,
}
func discover_npc(npc_id: String) -> void:
if npc_id not in _registry:
return
_registry[npc_id]["state"] = NPCState.IN_HUB
var npc := _load_npc(npc_id)
if npc:
EventBus.npc_saved.emit(npc)
func send_on_run(npc_id: String) -> void:
if npc_id in _registry:
_registry[npc_id]["state"] = NPCState.ON_RUN
func return_npc_to_hub(npc_id: String) -> void:
if npc_id in _registry:
_registry[npc_id]["state"] = NPCState.IN_HUB
func lose_npc(npc_id: String) -> void:
if npc_id not in _registry:
return
_registry[npc_id]["state"] = NPCState.LOST
var npc := _load_npc(npc_id)
if npc:
EventBus.npc_lost.emit(npc)
func get_hub_npc_ids() -> Array[String]:
var result: Array[String] = []
for npc_id in _registry:
if _registry[npc_id]["state"] == NPCState.IN_HUB:
result.append(npc_id)
return result
func get_state(npc_id: String) -> NPCState:
return _registry.get(npc_id, {}).get("state", NPCState.UNDISCOVERED)
func get_save_data() -> Dictionary:
var data := {}
for npc_id in _registry:
data[npc_id] = {
"state": _registry[npc_id]["state"],
"resource_path": _registry[npc_id]["resource_path"],
}
return data
func load_save_data(data: Dictionary) -> void:
_registry.clear()
for npc_id in data:
_registry[npc_id] = data[npc_id].duplicate()
func _load_npc(npc_id: String) -> Resource:
var path: String = _registry[npc_id].get("resource_path", "")
if path != "" and ResourceLoader.exists(path):
return load(path)
return null
+110
View File
@@ -0,0 +1,110 @@
extends CharacterBody2D
# ---------------------------------------------------------------------------
# Node refs
# ---------------------------------------------------------------------------
@onready var stats: Node = $Stats
@onready var body: Node2D = $Body
@onready var shoot_origin: Marker2D = $ShootOrigin
const PROJECTILE = preload("res://scenes/player/projectile.tscn")
const IFRAME_DURATION := 0.8
var _shoot_timer: float = 0.0
var _iframe_timer: float = 0.0
func _ready() -> void:
add_to_group("player")
# ---------------------------------------------------------------------------
# Physics
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
_handle_movement()
_handle_shooting(delta)
_handle_iframes(delta)
move_and_slide()
# ---------------------------------------------------------------------------
# Movement — WASD, 8-directional (direct key checks, no input map needed)
# ---------------------------------------------------------------------------
func _handle_movement() -> void:
var input_dir := Vector2.ZERO
if Input.is_physical_key_pressed(KEY_W): input_dir.y -= 1
if Input.is_physical_key_pressed(KEY_S): input_dir.y += 1
if Input.is_physical_key_pressed(KEY_A): input_dir.x -= 1
if Input.is_physical_key_pressed(KEY_D): input_dir.x += 1
if input_dir.length() > 1.0:
input_dir = input_dir.normalized()
velocity = input_dir * stats.speed
if input_dir.x != 0.0:
body.scale.x = sign(input_dir.x)
# ---------------------------------------------------------------------------
# Shooting — Arrow keys via built-in ui_ actions (always available in Godot)
# ---------------------------------------------------------------------------
func _handle_shooting(delta: float) -> void:
_shoot_timer = maxf(_shoot_timer - delta, 0.0)
var shoot_dir := Vector2.ZERO
if Input.is_action_pressed("ui_up"):
shoot_dir = Vector2.UP
elif Input.is_action_pressed("ui_down"):
shoot_dir = Vector2.DOWN
elif Input.is_action_pressed("ui_left"):
shoot_dir = Vector2.LEFT
elif Input.is_action_pressed("ui_right"):
shoot_dir = Vector2.RIGHT
if shoot_dir != Vector2.ZERO and _shoot_timer == 0.0:
_fire(shoot_dir)
_shoot_timer = 1.0 / stats.fire_rate
func _fire(direction: Vector2) -> void:
if stats.scatter_count <= 1:
_spawn_projectile(direction)
else:
var spread := deg_to_rad(stats.scatter_spread_deg)
var step := spread / float(stats.scatter_count - 1)
var start := direction.angle() - spread / 2.0
for i in stats.scatter_count:
_spawn_projectile(Vector2.from_angle(start + step * i))
func _spawn_projectile(dir: Vector2) -> void:
var proj: Area2D = PROJECTILE.instantiate()
proj.global_position = shoot_origin.global_position
proj.direction = dir
proj.damage = stats.damage
proj.max_range = stats.range_
proj.ricochet = stats.ricochet
proj.explosive = stats.explosive
proj.explosion_radius = stats.explosion_radius
proj.explosion_damage = stats.explosion_damage
get_parent().add_child(proj)
# ---------------------------------------------------------------------------
# Iframes — flash body during invincibility
# ---------------------------------------------------------------------------
func _handle_iframes(delta: float) -> void:
if _iframe_timer <= 0.0:
body.modulate = Color(1, 1, 1, 1)
return
_iframe_timer -= delta
# Visible flash every 0.1s
body.modulate = Color(1, 1, 1, 0.25) if fmod(_iframe_timer, 0.2) < 0.1 else Color(1, 1, 1, 1)
# ---------------------------------------------------------------------------
# Damage / death
# ---------------------------------------------------------------------------
func take_damage(amount: float) -> void:
if _iframe_timer > 0.0:
return
stats.current_health -= amount
_iframe_timer = IFRAME_DURATION
EventBus.player_health_changed.emit(stats.current_health, stats.max_health)
if stats.current_health <= 0.0:
_die()
func _die() -> void:
RunManager.end_run(false)
+144
View File
@@ -0,0 +1,144 @@
extends Node
# ---------------------------------------------------------------------------
# Base stats — no upgrades equipped
# ---------------------------------------------------------------------------
const BASE_SPEED := 200.0
const BASE_MAX_HEALTH := 6.0 # 6 HP = 3 full hearts
const BASE_DAMAGE := 3.5
const BASE_FIRE_RATE := 3.5 # shots per second
const BASE_RANGE := 400.0
const BASE_PROJ_SPEED := 520.0
# ---------------------------------------------------------------------------
# Live stats (recalculated from base + equipped parts + run items)
# ---------------------------------------------------------------------------
var speed: float = BASE_SPEED
var max_health: float = BASE_MAX_HEALTH
var current_health: float = BASE_MAX_HEALTH
var damage: float = BASE_DAMAGE
var fire_rate: float = BASE_FIRE_RATE
var range_: float = BASE_RANGE
var proj_speed: float = BASE_PROJ_SPEED
# ---------------------------------------------------------------------------
# Run-item behavior flags (recomputed each recalculate — never saved)
# ---------------------------------------------------------------------------
var ricochet: bool = false
var scatter_count: int = 1
var scatter_spread_deg: float = 0.0
var explosive: bool = false
var explosion_radius: float = 0.0
var explosion_damage: float = 0.0
# New item flags
var coolant_leak: bool = false
var scrap_magnet: bool = false
var memory_spike: bool = false
var rust_coat: bool = false
var static_discharge: bool = false
var fragmented_map: bool = false
var overclock: bool = false
var damage_reduction: float = 0.0
var discharge_radius: float = 64.0
var discharge_damage: float = 1.0
var magnet_radius: float = 0.0
# ---------------------------------------------------------------------------
# Body-part behavior flags (recomputed each recalculate)
# ---------------------------------------------------------------------------
var shield_projector: bool = false
func _ready() -> void:
EventBus.body_part_equipped.connect(_on_part_equipped)
recalculate()
if RunManager.player_health_carry > 0.0:
current_health = minf(RunManager.player_health_carry, max_health)
RunManager.player_health_carry = -1.0
func recalculate() -> void:
speed = BASE_SPEED + UpgradeManager.get_stat_modifier("speed")
max_health = BASE_MAX_HEALTH + UpgradeManager.get_stat_modifier("max_health")
damage = BASE_DAMAGE + UpgradeManager.get_stat_modifier("damage")
fire_rate = BASE_FIRE_RATE + UpgradeManager.get_stat_modifier("fire_rate")
range_ = BASE_RANGE + UpgradeManager.get_stat_modifier("range")
proj_speed = BASE_PROJ_SPEED + UpgradeManager.get_stat_modifier("proj_speed")
_apply_run_items()
_apply_part_flags()
current_health = minf(current_health, max_health)
func _apply_run_items() -> void:
ricochet = false
scatter_count = 1
scatter_spread_deg = 0.0
explosive = false
explosion_radius = 0.0
explosion_damage = 0.0
coolant_leak = false
scrap_magnet = false
memory_spike = false
rust_coat = false
static_discharge = false
fragmented_map = false
overclock = false
damage_reduction = 0.0
discharge_radius = 64.0
discharge_damage = 1.0
magnet_radius = 0.0
for item: RunItem in RunManager.run_items:
match item.effect:
RunItem.Effect.STAT_MOD:
match item.stat_key:
"speed": speed += item.stat_delta
"max_health": max_health += item.stat_delta
"damage": damage += item.stat_delta
"fire_rate": fire_rate += item.stat_delta
"range": range_ += item.stat_delta
RunItem.Effect.RICOCHET:
ricochet = true
RunItem.Effect.SCATTER:
scatter_count = maxi(scatter_count, item.scatter_count)
scatter_spread_deg = maxf(scatter_spread_deg, item.scatter_spread_deg)
RunItem.Effect.EXPLOSIVE:
explosive = true
explosion_radius = maxf(explosion_radius, item.explosion_radius)
explosion_damage = maxf(explosion_damage, item.explosion_damage)
RunItem.Effect.COOLANT_LEAK:
coolant_leak = true
RunItem.Effect.SCRAP_MAGNET:
scrap_magnet = true
magnet_radius = maxf(magnet_radius, item.magnet_radius)
RunItem.Effect.MEMORY_SPIKE:
memory_spike = true
RunItem.Effect.RUST_COAT:
rust_coat = true
damage_reduction = maxf(damage_reduction, item.damage_reduction)
RunItem.Effect.STATIC_DISCHARGE:
static_discharge = true
discharge_radius = maxf(discharge_radius, item.discharge_radius)
discharge_damage = maxf(discharge_damage, item.discharge_damage)
RunItem.Effect.FRAGMENTED_MAP:
fragmented_map = true
RunItem.Effect.OVERCLOCK:
overclock = true
fire_rate *= 1.4
func _apply_part_flags() -> void:
shield_projector = false
# Scatter from body parts (accumulate with run item scatter)
for slot in UpgradeManager.SLOTS:
var part := UpgradeManager.get_equipped_part(slot)
if part == null:
continue
if part.shield_projector:
shield_projector = true
if part.mod_scatter_count > 0:
scatter_count = maxi(scatter_count, part.mod_scatter_count)
scatter_spread_deg = maxf(scatter_spread_deg, part.mod_scatter_spread_deg)
func heal(amount: float) -> void:
current_health = minf(current_health + amount, max_health)
func _on_part_equipped(_part: Resource, _slot: String) -> void:
recalculate()
+89
View File
@@ -0,0 +1,89 @@
extends Area2D
# Set by player on spawn
var direction: Vector2 = Vector2.RIGHT
var damage: float = 3.5
var speed: float = 520.0
var max_range: float = 400.0
# Behavior flags (set from player stats)
var ricochet: bool = false
var explosive: bool = false
var explosion_radius: float = 64.0
var explosion_damage: float = 2.5
var _distance_traveled: float = 0.0
var _bounces_left: int = 1
var _bounce_cooldown: float = 0.0 # grace period after bounce to avoid re-trigger
func _ready() -> void:
rotation = direction.angle()
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
_bounce_cooldown = maxf(_bounce_cooldown - delta, 0.0)
if ricochet and _bounces_left > 0 and _bounce_cooldown == 0.0:
_check_wall_bounce(delta)
var move := direction * speed * delta
position += move
_distance_traveled += move.length()
if _distance_traveled >= max_range:
queue_free()
# ---------------------------------------------------------------------------
# Ricochet — raycast lookahead to get wall normal before physically touching
# ---------------------------------------------------------------------------
func _check_wall_bounce(delta: float) -> void:
var space := get_world_2d().direct_space_state
var reach := speed * delta + 6.0
var params := PhysicsRayQueryParameters2D.create(
global_position,
global_position + direction * reach,
1 # collision_mask: walls on layer 1
)
var result := space.intersect_ray(params)
if result and result["collider"] is StaticBody2D:
direction = direction.bounce(result["normal"])
rotation = direction.angle()
_bounces_left -= 1
_bounce_cooldown = 0.1
# ---------------------------------------------------------------------------
# Placeholder visual — yellow circle
# ---------------------------------------------------------------------------
func _draw() -> void:
draw_circle(Vector2.ZERO, 5.0, Color(1.0, 0.85, 0.2))
# ---------------------------------------------------------------------------
# Collision — differentiate walls from enemies
# ---------------------------------------------------------------------------
func _on_body_entered(body: Node) -> void:
if body is StaticBody2D:
# Wall hit — ignore during bounce cooldown (already reflected by raycast)
if _bounce_cooldown > 0.0:
return
if not ricochet or _bounces_left <= 0:
queue_free()
return
if body.has_method("take_damage"):
body.take_damage(damage)
if explosive:
_explode()
queue_free()
# ---------------------------------------------------------------------------
# Explosive — AoE damage to all enemies in radius
# ---------------------------------------------------------------------------
func _explode() -> void:
var space := get_world_2d().direct_space_state
var query := PhysicsShapeQueryParameters2D.new()
var circle := CircleShape2D.new()
circle.radius = explosion_radius
query.shape = circle
query.transform = Transform2D(0.0, global_position)
query.collision_mask = 1
for r in space.intersect_shape(query):
var c = r["collider"]
if c.has_method("take_damage"):
c.take_damage(explosion_damage)
+92
View File
@@ -0,0 +1,92 @@
extends Node2D
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const ROOM_SCENE = preload("res://scenes/run/rooms/room_base.tscn")
const ROOM_W := 960.0
const ROOM_H := 540.0
const TRANSITION_TIME := 0.25
const ENTRY_INSET := 60.0 # how far from wall edge the player appears
# ---------------------------------------------------------------------------
# Node refs
# ---------------------------------------------------------------------------
@onready var rooms_container: Node2D = $Rooms
@onready var camera: Camera2D = $Camera2D
@onready var player: Node2D = $Player
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _rooms: Dictionary = {} # Vector2i -> Room
var _current_grid: Vector2i
var _transitioning: bool = false
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
func _ready() -> void:
var floor_num: int = max(RunManager.current_floor, 1)
_build_floor(FloorGenerator.generate(floor_num))
func _build_floor(floor_data: Array) -> void:
var start_pos := Vector2i.ZERO
for room_data in floor_data:
var room_node: Room = ROOM_SCENE.instantiate()
room_node.position = _grid_to_world(room_data.grid_pos)
rooms_container.add_child(room_node)
room_node.setup(room_data, self)
_rooms[room_data.grid_pos] = room_node
if room_data.type == RoomData.RoomType.START:
start_pos = room_data.grid_pos
# Snap camera + player to start room
var world_start := _grid_to_world(start_pos)
camera.position = world_start
player.global_position = world_start
_current_grid = start_pos
_rooms[start_pos].activate()
# ---------------------------------------------------------------------------
# Transitions
# ---------------------------------------------------------------------------
func transition_to(target_grid: Vector2i, from_direction: String) -> void:
if _transitioning:
return
_transitioning = true
_current_grid = target_grid
var new_world := _grid_to_world(target_grid)
# Teleport player to the entry side of the new room immediately
player.global_position = new_world + _entry_offset(from_direction)
# Activate the room (may spawn enemies) — we're outside physics here
_rooms[target_grid].activate()
# Slide the camera
var tween := create_tween()
tween.tween_property(camera, "position", new_world, TRANSITION_TIME)
tween.tween_callback(func(): _transitioning = false)
func _entry_offset(from_direction: String) -> Vector2:
# Player exits through `from_direction`; they enter the new room
# from the opposite side.
var i := ENTRY_INSET
match from_direction:
"up": return Vector2(0, ROOM_H / 2.0 - i) # enter at bottom
"down": return Vector2(0, -ROOM_H / 2.0 + i) # enter at top
"left": return Vector2( ROOM_W / 2.0 - i, 0) # enter at right
"right": return Vector2(-ROOM_W / 2.0 + i, 0) # enter at left
return Vector2.ZERO
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _grid_to_world(grid_pos: Vector2i) -> Vector2:
return Vector2(grid_pos.x * ROOM_W, grid_pos.y * ROOM_H)
+118
View File
@@ -0,0 +1,118 @@
class_name FloorGenerator
const GRID_START := Vector2i(5, 5)
const MIN_ROOMS := 8
const MAX_ROOMS := 12
const DIRS := {
"up": Vector2i( 0, -1),
"down": Vector2i( 0, 1),
"left": Vector2i(-1, 0),
"right": Vector2i( 1, 0),
}
const OPPOSITE := {
"up": "down", "down": "up", "left": "right", "right": "left",
}
# Returns Array of RoomData covering one floor layout.
static func generate(floor_number: int) -> Array:
var rng := RandomNumberGenerator.new()
rng.randomize()
var rooms: Dictionary = {} # Vector2i -> RoomData
# --- Start room ---
var start := RoomData.new()
start.type = RoomData.RoomType.START
start.grid_pos = GRID_START
rooms[GRID_START] = start
var queue: Array = [GRID_START]
var target: int = rng.randi_range(MIN_ROOMS, MAX_ROOMS)
# --- Random walk ---
while rooms.size() < target and queue.size() > 0:
var pos: Vector2i = queue[rng.randi() % queue.size()]
for dir_name in _shuffled(rng, ["up", "down", "left", "right"]):
if rooms.size() >= target:
break
var next: Vector2i = pos + DIRS[dir_name]
if rooms.has(next):
continue
# Skip if the candidate cell already has >1 existing neighbor
# (prevents overly dense clusters)
var neighbor_count := 0
for d in DIRS.values():
if rooms.has(next + d):
neighbor_count += 1
if neighbor_count > 1:
continue
var r := RoomData.new()
r.type = RoomData.RoomType.COMBAT
r.grid_pos = next
rooms[next] = r
queue.append(next)
# Bidirectional connection
rooms[pos].connections[dir_name] = next
r.connections[OPPOSITE[dir_name]] = pos
# --- Boss: farthest room from start ---
var boss_pos := _farthest(GRID_START, rooms)
rooms[boss_pos].type = RoomData.RoomType.BOSS
# --- Item + Shop: dead-end COMBAT rooms ---
var dead_ends: Array = []
for p in rooms:
var rd: RoomData = rooms[p]
if rd.type == RoomData.RoomType.COMBAT and rd.connections.size() == 1:
dead_ends.append(p)
dead_ends.shuffle()
if dead_ends.size() >= 1:
rooms[dead_ends[0]].type = RoomData.RoomType.ITEM
if dead_ends.size() >= 2:
rooms[dead_ends[1]].type = RoomData.RoomType.SHOP
return rooms.values()
static func _shuffled(rng: RandomNumberGenerator, arr: Array) -> Array:
var result := arr.duplicate()
for i in range(result.size() - 1, 0, -1):
var j := rng.randi() % (i + 1)
var tmp = result[i]
result[i] = result[j]
result[j] = tmp
return result
static func _farthest(start: Vector2i, rooms: Dictionary) -> Vector2i:
var visited: Dictionary = {}
var queue: Array = [[start, 0]]
var farthest := start
var max_dist := 0
while queue.size() > 0:
var entry = queue.pop_front()
var pos: Vector2i = entry[0]
var dist: int = entry[1]
if visited.has(pos):
continue
visited[pos] = true
if dist > max_dist:
max_dist = dist
farthest = pos
var rd: RoomData = rooms[pos]
for connected in rd.connections.values():
if not visited.has(connected):
queue.append([connected, dist + 1])
return farthest
+332
View File
@@ -0,0 +1,332 @@
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 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/head_sensor_array.tres",
"res://data/body_parts/torso_reinforced_plating.tres",
"res://data/body_parts/left_arm_rapid_fire.tres",
"res://data/body_parts/right_arm_cannon.tres",
"res://data/body_parts/legs_servo_boost.tres",
]
const ALL_ITEM_PATHS: Array[String] = [
"res://data/run_items/plating_shard.tres",
"res://data/run_items/range_booster.tres",
"res://data/run_items/overclock_chip.tres",
"res://data/run_items/ricochet_module.tres",
"res://data/run_items/scatter_core.tres",
"res://data/run_items/volatile_round.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:
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
_:
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:
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 boss = SUPERVISOR_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()
_update_door_locks()
func _spawn_floor_exit() -> void:
# 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 = "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
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)
# ---------------------------------------------------------------------------
# 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
+9
View File
@@ -0,0 +1,9 @@
class_name RoomData
enum RoomType { START, COMBAT, ITEM, SHOP, BOSS }
var type: RoomType = RoomType.COMBAT
var grid_pos: Vector2i = Vector2i.ZERO
var connections: Dictionary = {} # "up"/"down"/"left"/"right" -> Vector2i
var visited: bool = false
var cleared: bool = false
+71
View File
@@ -0,0 +1,71 @@
extends Node
var current_floor: int = 0
var current_room_id: String = ""
var run_active: bool = false
# --- Collected this run (lost on death) ---
var run_items: Array[Resource] = []
var card_packs_found: Array[Resource] = []
# --- Brought into the run (lost on death) ---
var credits_brought_in: int = 0
var buff_items_brought: Array[Resource] = []
var npc_ids_on_run: Array[String] = []
# --- Floor-to-floor carry (not saved, only lives for one scene reload) ---
var player_health_carry: float = -1.0 # -1 = no carry, use default full health
func start_run(brought_credits: int, brought_buffs: Array[Resource], brought_npc_ids: Array[String]) -> void:
current_floor = 1
current_room_id = ""
run_active = true
player_health_carry = -1.0
run_items.clear()
card_packs_found.clear()
credits_brought_in = brought_credits
buff_items_brought = brought_buffs.duplicate()
npc_ids_on_run = brought_npc_ids.duplicate()
EventBus.run_started.emit()
func end_run(player_survived: bool) -> void:
run_active = false
if player_survived:
_apply_run_rewards()
else:
_apply_run_death()
EventBus.run_ended.emit(player_survived)
func advance_floor() -> void:
EventBus.floor_cleared.emit(current_floor)
current_floor += 1
func collect_item(item: Resource) -> void:
run_items.append(item)
EventBus.item_collected.emit(item)
func collect_card_pack(pack: Resource) -> void:
card_packs_found.append(pack)
EventBus.card_pack_found.emit(pack)
func _apply_run_rewards() -> void:
# Permanent: card packs go to collection
for pack in card_packs_found:
CardCollection.open_pack(pack)
# Credits brought in are returned plus any earned during the run
UpgradeManager.hub_credits += credits_brought_in
# NPCs survive
for npc_id in npc_ids_on_run:
NPCManager.return_npc_to_hub(npc_id)
func _apply_run_death() -> void:
# Everything brought in / found is lost
run_items.clear()
card_packs_found.clear()
credits_brought_in = 0
buff_items_brought.clear()
# NPCs on run are lost
for npc_id in npc_ids_on_run:
NPCManager.lose_npc(npc_id)
npc_ids_on_run.clear()
EventBus.player_died.emit()
+27
View File
@@ -0,0 +1,27 @@
extends Node
const SAVE_PATH := "user://save.cfg"
func save() -> void:
var data := ConfigFile.new()
data.set_value("upgrades", "parts", UpgradeManager.get_save_data())
data.set_value("upgrades", "hub_credits", UpgradeManager.hub_credits)
data.set_value("cards", "collection", CardCollection.get_save_data())
data.set_value("npcs", "registry", NPCManager.get_save_data())
data.set_value("lore", "discovered", LoreManager.get_save_data())
data.save(SAVE_PATH)
func load_save() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var data := ConfigFile.new()
data.load(SAVE_PATH)
UpgradeManager.load_save_data(data.get_value("upgrades", "parts", {}))
UpgradeManager.hub_credits = data.get_value("upgrades", "hub_credits", 0)
CardCollection.load_save_data(data.get_value("cards", "collection", {}))
NPCManager.load_save_data(data.get_value("npcs", "registry", {}))
LoreManager.load_save_data(data.get_value("lore", "discovered", []))
func delete_save() -> void:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
+167
View File
@@ -0,0 +1,167 @@
extends CanvasLayer
# ---------------------------------------------------------------------------
# Layout constants (screen space: 1920 x 1080)
# ---------------------------------------------------------------------------
const SCREEN_W := 1920.0
const SCREEN_H := 1080.0
const PANEL_W := 620.0
const PANEL_H := 500.0
const LINE_H := 36.0
const LIST_PAD_X := 24.0
const LIST_PAD_Y := 60.0
const BG_COLOR := Color(0.06, 0.07, 0.12, 0.94)
const CURSOR_COLOR := Color(0.9, 0.85, 0.3, 1)
const NORMAL_COLOR := Color(0.75, 0.75, 0.85, 1)
const EQUIPPED_COLOR := Color(0.4, 0.9, 0.6, 1)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _items: Array = [] # Array[BodyPartData] — sorted by slot
var _cursor: int = 0
var _item_labels: Array = [] # Array[Label]
# ---------------------------------------------------------------------------
# UI nodes (built once in _ready)
# ---------------------------------------------------------------------------
var _bg: Polygon2D
var _title_lbl: Label
var _hint_lbl: Label
var _list_root: Node2D # parent for item labels (positioned in screen space)
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
func _ready() -> void:
_build_static_ui()
hide()
func _build_static_ui() -> void:
var cx := SCREEN_W / 2.0
var cy := SCREEN_H / 2.0
var hw := PANEL_W / 2.0
var hh := PANEL_H / 2.0
# Background panel
_bg = Polygon2D.new()
_bg.polygon = PackedVector2Array([
Vector2(-hw, -hh), Vector2(hw, -hh),
Vector2( hw, hh), Vector2(-hw, hh),
])
_bg.color = BG_COLOR
_bg.position = Vector2(cx, cy)
add_child(_bg)
# Title
_title_lbl = Label.new()
_title_lbl.text = "— ARMORY —"
_title_lbl.position = Vector2(cx - 60, cy - hh + 14)
add_child(_title_lbl)
# Hint bar
_hint_lbl = Label.new()
_hint_lbl.text = "[↑↓] Navigate [Enter] Equip [Esc] Close"
_hint_lbl.position = Vector2(cx - hw + LIST_PAD_X, cy + hh - 30)
add_child(_hint_lbl)
# Container node for item labels
_list_root = Node2D.new()
_list_root.position = Vector2(cx - hw + LIST_PAD_X, cy - hh + LIST_PAD_Y)
add_child(_list_root)
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("ui_up"):
_cursor = wrapi(_cursor - 1, 0, maxi(1, _items.size()))
_refresh_labels()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_down"):
_cursor = wrapi(_cursor + 1, 0, maxi(1, _items.size()))
_refresh_labels()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_accept"):
_equip_selected()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_cancel"):
hide()
get_viewport().set_input_as_handled()
# ---------------------------------------------------------------------------
# Open — call from hub.gd
# ---------------------------------------------------------------------------
func open() -> void:
_rebuild_items()
_cursor = 0
_refresh_labels()
show()
# ---------------------------------------------------------------------------
# Rebuild item list from UpgradeManager
# ---------------------------------------------------------------------------
func _rebuild_items() -> void:
_items.clear()
for path: String in UpgradeManager.acquired_part_paths:
if path == "":
continue
var part := load(path) as BodyPartData
if part:
_items.append(part)
# Sort by slot order so the list groups naturally
var slot_order := UpgradeManager.SLOTS
_items.sort_custom(func(a, b): return slot_order.find(a.slot) < slot_order.find(b.slot))
# Destroy old labels
for lbl: Label in _item_labels:
lbl.queue_free()
_item_labels.clear()
# Create one label per item
for i in _items.size():
var lbl := Label.new()
lbl.position = Vector2(0, i * LINE_H)
_list_root.add_child(lbl)
_item_labels.append(lbl)
func _refresh_labels() -> void:
for i in _item_labels.size():
var part: BodyPartData = _items[i]
var equipped_path: String = UpgradeManager.equipped_parts.get(part.slot, "")
var is_equipped := equipped_path == part.resource_path
var is_cursor := i == _cursor
var slot_tag := "[%s]" % part.slot.to_upper()
var equip_tag := "" if is_equipped else ""
var lbl: Label = _item_labels[i]
lbl.text = ("%s %s%s" % [slot_tag, part.display_name, equip_tag]).lstrip("")
if is_cursor:
lbl.add_theme_color_override("font_color", CURSOR_COLOR)
elif is_equipped:
lbl.add_theme_color_override("font_color", EQUIPPED_COLOR)
else:
lbl.add_theme_color_override("font_color", NORMAL_COLOR)
# ---------------------------------------------------------------------------
# Equip the part under the cursor
# ---------------------------------------------------------------------------
func _equip_selected() -> void:
if _items.is_empty():
return
var part: BodyPartData = _items[_cursor]
UpgradeManager.equip_part(part)
_refresh_labels()
+195
View File
@@ -0,0 +1,195 @@
extends CanvasLayer
# ---------------------------------------------------------------------------
# Heart geometry — two polygons per slot: background (dark) + fill (red)
# ---------------------------------------------------------------------------
# Full heart, centered at origin, ~40 wide x 36 tall
var HEART_FULL := PackedVector2Array([
Vector2( 0, 18), Vector2(-15, 3), Vector2(-20, -6),
Vector2(-15, -15), Vector2(-6, -18), Vector2(-2, -14),
Vector2( 0, -11),
Vector2( 2, -14), Vector2( 6, -18), Vector2(15, -15),
Vector2( 20, -6), Vector2(15, 3),
])
# Left half only — used for the half-heart fill
var HEART_HALF := PackedVector2Array([
Vector2( 0, 18), Vector2(-15, 3), Vector2(-20, -6),
Vector2(-15, -15), Vector2(-6, -18), Vector2(-2, -14),
Vector2( 0, -11),
])
const FULL_COLOR := Color(0.90, 0.10, 0.10) # bright red
const EMPTY_COLOR := Color(0.18, 0.04, 0.04) # dark maroon (background)
const HEART_SPACING := 52 # px between heart centers
const ORIGIN := Vector2(32, 32) # center of first heart (screen px)
# ---------------------------------------------------------------------------
# Boss bar
# ---------------------------------------------------------------------------
const BOSS_BAR_W := 400.0
const BOSS_BAR_H := 18.0
const BOSS_BAR_POS := Vector2(280.0, 490.0) # top-left corner (960×540 viewport)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
# Each entry: { "bg": Polygon2D, "fill": Polygon2D }
var _slots: Array = []
var _credits_label: Label
# Boss bar nodes (null when not active)
var _boss_bar_root: Node2D = null
var _boss_bar_fill: Polygon2D = null
var _boss_bar_label: Label = null
var _boss_max_hp: float = 1.0
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
func _ready() -> void:
EventBus.player_health_changed.connect(_on_health_changed)
EventBus.credits_changed.connect(_on_credits_changed)
EventBus.boss_health_changed.connect(_on_boss_health_changed)
EventBus.boss_died.connect(_on_boss_died)
_build_credits_label()
# Initialise once the scene is fully set up
call_deferred("_init_from_player")
func _init_from_player() -> void:
var p = get_tree().get_first_node_in_group("player")
if p:
_rebuild(p.stats.max_health, p.stats.current_health)
else:
_rebuild(6.0, 6.0)
# ---------------------------------------------------------------------------
# Build / rebuild all heart slots
# ---------------------------------------------------------------------------
func _rebuild(max_hp: float, current_hp: float) -> void:
for slot in _slots:
slot["bg"].queue_free()
slot["fill"].queue_free()
_slots.clear()
var num_hearts := int(max_hp / 2.0)
for i in num_hearts:
var center := ORIGIN + Vector2(i * HEART_SPACING, 0)
# Background — always visible, shows the "empty" container
var bg := Polygon2D.new()
bg.polygon = HEART_FULL
bg.color = EMPTY_COLOR
bg.position = center
add_child(bg)
# Fill — overlaid on top; polygon shape changes per fill level
var fill := Polygon2D.new()
fill.polygon = HEART_FULL
fill.color = FULL_COLOR
fill.position = center
add_child(fill)
_slots.append({"bg": bg, "fill": fill})
_update_display(current_hp)
# ---------------------------------------------------------------------------
# Update fill polygons to reflect current health
# ---------------------------------------------------------------------------
func _update_display(current_hp: float) -> void:
for i in _slots.size():
var fill: Polygon2D = _slots[i]["fill"]
var hp_in_slot := current_hp - i * 2.0
if hp_in_slot >= 2.0:
fill.polygon = HEART_FULL
fill.visible = true
elif hp_in_slot >= 1.0:
fill.polygon = HEART_HALF
fill.visible = true
else:
fill.visible = false
# ---------------------------------------------------------------------------
# Credits label
# ---------------------------------------------------------------------------
func _build_credits_label() -> void:
_credits_label = Label.new()
_credits_label.position = Vector2(16, 72)
_credits_label.text = "SCRAP: %d" % UpgradeManager.hub_credits
add_child(_credits_label)
func _on_credits_changed(new_total: int) -> void:
_credits_label.text = "SCRAP: %d" % new_total
# ---------------------------------------------------------------------------
# Signal handler
# ---------------------------------------------------------------------------
func _on_health_changed(current_hp: float, max_hp: float) -> void:
var num_hearts := int(max_hp / 2.0)
if num_hearts != _slots.size():
_rebuild(max_hp, current_hp)
else:
_update_display(current_hp)
# ---------------------------------------------------------------------------
# Boss health bar
# ---------------------------------------------------------------------------
func _on_boss_health_changed(current_hp: float, max_hp: float) -> void:
if _boss_bar_root == null:
_build_boss_bar(max_hp)
_boss_max_hp = max_hp
_update_boss_bar(current_hp)
func _on_boss_died() -> void:
if _boss_bar_root != null:
_boss_bar_root.queue_free()
_boss_bar_root = null
_boss_bar_fill = null
_boss_bar_label = null
func _build_boss_bar(max_hp: float) -> void:
_boss_max_hp = max_hp
_boss_bar_root = Node2D.new()
_boss_bar_root.position = BOSS_BAR_POS
add_child(_boss_bar_root)
# Name label
_boss_bar_label = Label.new()
_boss_bar_label.text = "THE SUPERVISOR"
_boss_bar_label.position = Vector2(0, -22)
_boss_bar_root.add_child(_boss_bar_label)
# Background
var bg := Polygon2D.new()
bg.polygon = PackedVector2Array([
Vector2(0, 0), Vector2(BOSS_BAR_W, 0),
Vector2(BOSS_BAR_W, BOSS_BAR_H), Vector2(0, BOSS_BAR_H),
])
bg.color = Color(0.15, 0.05, 0.05)
_boss_bar_root.add_child(bg)
# Fill
_boss_bar_fill = Polygon2D.new()
_boss_bar_fill.polygon = PackedVector2Array([
Vector2(0, 0), Vector2(BOSS_BAR_W, 0),
Vector2(BOSS_BAR_W, BOSS_BAR_H), Vector2(0, BOSS_BAR_H),
])
_boss_bar_fill.color = Color(0.85, 0.1, 0.1)
_boss_bar_root.add_child(_boss_bar_fill)
func _update_boss_bar(current_hp: float) -> void:
if _boss_bar_fill == null:
return
var pct := clampf(current_hp / _boss_max_hp, 0.0, 1.0)
var w := BOSS_BAR_W * pct
_boss_bar_fill.polygon = PackedVector2Array([
Vector2(0, 0), Vector2(w, 0),
Vector2(w, BOSS_BAR_H), Vector2(0, BOSS_BAR_H),
])
+29
View File
@@ -0,0 +1,29 @@
extends Resource
class_name BodyPartData
@export var slot: String = "" # "head" | "torso" | "left_arm" | "right_arm" | "legs"
@export var display_name: String = ""
# ---------------------------------------------------------------------------
# Stat modifiers — additive bonuses stacked across all equipped parts.
# Field names mirror the stat keys used in player_stats.gd.
# ---------------------------------------------------------------------------
@export var mod_speed: float = 0.0
@export var mod_max_health: float = 0.0
@export var mod_damage: float = 0.0
@export var mod_fire_rate: float = 0.0
@export var mod_range: float = 0.0
@export var mod_proj_speed: float = 0.0 # flat delta; e.g. -104 = -20% of base 520
# ---------------------------------------------------------------------------
# Scatter (left arm slot)
# mod_scatter_count > 0 enables multi-shot spread from this part
# ---------------------------------------------------------------------------
@export var mod_scatter_count: int = 0
@export var mod_scatter_spread_deg: float = 0.0
# ---------------------------------------------------------------------------
# Shield Projector (left arm slot)
# Active block on cooldown — absorbs one hit per activation
# ---------------------------------------------------------------------------
@export var shield_projector: bool = false
+11
View File
@@ -0,0 +1,11 @@
extends Area2D
var part: BodyPartData = null
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
if body.is_in_group("player") and part != null:
UpgradeManager.acquire_part(part)
queue_free()
+63
View File
@@ -0,0 +1,63 @@
extends Node
const SLOTS: Array[String] = ["head", "torso", "left_arm", "right_arm", "legs"]
var hub_credits: int = 0
# slot -> BodyPart resource path (empty string = default/base part)
var equipped_parts: Dictionary = {
"head": "",
"torso": "",
"left_arm": "",
"right_arm": "",
"legs": "",
}
# All permanently acquired part resource paths
var acquired_part_paths: Array[String] = []
func equip_part(part: Resource) -> void:
var slot: String = part.slot
if slot not in SLOTS:
push_error("UpgradeManager: invalid slot '%s'" % slot)
return
equipped_parts[slot] = part.resource_path
EventBus.body_part_equipped.emit(part, slot)
func acquire_part(part: Resource) -> void:
if part.resource_path not in acquired_part_paths:
acquired_part_paths.append(part.resource_path)
EventBus.body_part_found.emit(part)
func get_equipped_part(slot: String) -> Resource:
var path: String = equipped_parts.get(slot, "")
if path != "":
return load(path)
return null
func get_stat_modifier(stat: String) -> float:
var total := 0.0
for slot in SLOTS:
var part := get_equipped_part(slot)
if part == null:
continue
match stat:
"speed": total += part.mod_speed
"max_health": total += part.mod_max_health
"damage": total += part.mod_damage
"fire_rate": total += part.mod_fire_rate
"range": total += part.mod_range
"proj_speed": total += part.mod_proj_speed
return total
func get_save_data() -> Dictionary:
return {
"equipped": equipped_parts.duplicate(),
"acquired": acquired_part_paths.duplicate(),
}
func load_save_data(data: Dictionary) -> void:
var saved_equipped: Dictionary = data.get("equipped", {})
for slot in SLOTS:
equipped_parts[slot] = saved_equipped.get(slot, "")
acquired_part_paths = data.get("acquired", [])