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:
@@ -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", [])
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
])
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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", [])
|
||||
Reference in New Issue
Block a user