Sync working project files - hub layout, boss roster, doc updates
This commit is contained in:
+51
-79
@@ -1,93 +1,95 @@
|
||||
extends CharacterBody2D
|
||||
extends EnemyBase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ANCHOR — Structural integrity corruption
|
||||
# Pathfinds to nearest wall, locks in. Fires slow homing projectiles.
|
||||
# Does not move once anchored.
|
||||
# Drifts to a random floor point, anchors for a random duration while firing
|
||||
# homing projectiles, then picks a new point. Invincible while drifting.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
|
||||
|
||||
enum State { SEEKING, ANCHORED }
|
||||
enum State { DRIFTING, 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
|
||||
# Margin from walls so it stays on the floor interior
|
||||
const FLOOR_MARGIN := 80.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
|
||||
const SHOT_COOLDOWN := 2.2
|
||||
const ANCHOR_TIME_MIN := 3.0
|
||||
const ANCHOR_TIME_MAX := 6.0
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
var _state: State = State.DRIFTING
|
||||
var _target_pos: Vector2
|
||||
var _shot_timer: float = 1.0
|
||||
var _anchor_timer: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
_compute_wall_target()
|
||||
max_health = 40.0
|
||||
current_health = 40.0
|
||||
speed = 95.0
|
||||
contact_damage = 0.5
|
||||
super._ready()
|
||||
_pick_floor_point()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Find the nearest wall face and set it as the target
|
||||
# Pick a random point within the room interior
|
||||
# ---------------------------------------------------------------------------
|
||||
func _compute_wall_target() -> void:
|
||||
func _pick_floor_point() -> 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)
|
||||
var rx: float = randf_range(-INNER_HALF_X + FLOOR_MARGIN, INNER_HALF_X - FLOOR_MARGIN)
|
||||
var ry: float = randf_range(-INNER_HALF_Y + FLOOR_MARGIN, INNER_HALF_Y - FLOOR_MARGIN)
|
||||
_target_pos = room_center + Vector2(rx, ry)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_find_player()
|
||||
_tick_contact(delta)
|
||||
_tick_flash(delta)
|
||||
_tick_slow(delta)
|
||||
|
||||
match _state:
|
||||
State.SEEKING:
|
||||
State.DRIFTING:
|
||||
_do_seek()
|
||||
State.ANCHORED:
|
||||
_do_anchored(delta)
|
||||
|
||||
velocity *= _slow_factor
|
||||
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")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invincible while drifting
|
||||
# ---------------------------------------------------------------------------
|
||||
func take_damage(amount: float) -> void:
|
||||
if _state == State.DRIFTING:
|
||||
return
|
||||
super.take_damage(amount)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Move toward the nearest wall, then lock in
|
||||
# Drift toward target floor point, then anchor
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_seek() -> void:
|
||||
var diff := _target_wall - global_position
|
||||
var diff := _target_pos - global_position
|
||||
if diff.length() < 12.0:
|
||||
velocity = Vector2.ZERO
|
||||
_state = State.ANCHORED
|
||||
velocity = Vector2.ZERO
|
||||
_state = State.ANCHORED
|
||||
_shot_timer = SHOT_COOLDOWN * 0.5
|
||||
_anchor_timer = randf_range(ANCHOR_TIME_MIN, ANCHOR_TIME_MAX)
|
||||
else:
|
||||
velocity = diff.normalized() * speed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stationary — fire slow homing projectiles at the player
|
||||
# Anchored — fire homing projectiles; leave when anchor_timer expires
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_anchored(delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
_anchor_timer -= delta
|
||||
if _anchor_timer <= 0.0:
|
||||
_state = State.DRIFTING
|
||||
_pick_floor_point()
|
||||
return
|
||||
|
||||
_shot_timer -= delta
|
||||
if _shot_timer <= 0.0 and _player != null:
|
||||
_fire_at_player()
|
||||
@@ -103,34 +105,4 @@ func _fire_at_player() -> void:
|
||||
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)
|
||||
get_parent().call_deferred("add_child", proj)
|
||||
@@ -10,8 +10,19 @@ 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 _physics_process(delta: float) -> void:
|
||||
var player: Node2D = get_tree().get_first_node_in_group("player") as Node2D
|
||||
if player == null:
|
||||
return
|
||||
var pstats: Node = player.get_node_or_null("Stats")
|
||||
if pstats == null or not pstats.get("scrap_magnet"):
|
||||
return
|
||||
var dist: float = global_position.distance_to(player.global_position)
|
||||
if dist <= float(pstats.get("magnet_radius")) and dist > 1.0:
|
||||
var dir: Vector2 = (player.global_position - global_position).normalized()
|
||||
global_position += dir * 240.0 * delta
|
||||
|
||||
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()
|
||||
RunManager.earn_credits(value)
|
||||
queue_free()
|
||||
+11
-51
@@ -1,40 +1,30 @@
|
||||
extends CharacterBody2D
|
||||
extends EnemyBase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
var _wobble_time: float = 0.0
|
||||
var _wobble_phase: float = 0.0 # random start so groups don't sync
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
max_health = 24.0
|
||||
current_health = 24.0
|
||||
speed = 100.0
|
||||
contact_damage = 1.0
|
||||
super._ready()
|
||||
_wobble_phase = randf() * TAU
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_find_player()
|
||||
_tick_contact(delta)
|
||||
_tick_flash(delta)
|
||||
_tick_slow(delta)
|
||||
_do_arc_chase(delta)
|
||||
velocity *= _slow_factor
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,34 +36,4 @@ func _do_arc_chase(delta: float) -> void:
|
||||
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)
|
||||
velocity = (toward + perp * wobble).normalized() * speed
|
||||
@@ -1,4 +1,5 @@
|
||||
extends CharacterBody2D
|
||||
class_name EnemyBase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stats — override in derived enemy types
|
||||
@@ -10,11 +11,13 @@ var contact_damage: float = 1.0
|
||||
var contact_cooldown: float = 0.8
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# Internal state — shared across all enemies
|
||||
# ---------------------------------------------------------------------------
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
var _player: Node2D = null
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
var _slow_factor: float = 1.0
|
||||
var _slow_timer: float = 0.0
|
||||
var _player: Node2D = null
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
|
||||
@@ -22,22 +25,41 @@ 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
|
||||
# Shared helpers — call from derived _physics_process as needed
|
||||
# ---------------------------------------------------------------------------
|
||||
func _chase_player() -> void:
|
||||
func _find_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
|
||||
|
||||
func _tick_contact(delta: float) -> void:
|
||||
if _contact_timer > 0.0:
|
||||
_contact_timer -= delta
|
||||
if _contact_timer <= 0.0:
|
||||
_contact_timer = 0.0
|
||||
# Re-apply damage if player is still overlapping after cooldown
|
||||
for body in $ContactArea.get_overlapping_bodies():
|
||||
if body.is_in_group("player"):
|
||||
body.take_damage(contact_damage)
|
||||
_contact_timer = contact_cooldown
|
||||
break
|
||||
|
||||
func _tick_flash(delta: float) -> void:
|
||||
if _flash_timer > 0.0:
|
||||
_flash_timer -= delta
|
||||
visual.modulate = Color(2.5, 2.5, 2.5)
|
||||
else:
|
||||
velocity = Vector2.ZERO
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
|
||||
func apply_slow(duration: float, factor: float) -> void:
|
||||
_slow_factor = factor
|
||||
_slow_timer = duration
|
||||
|
||||
func _tick_slow(delta: float) -> void:
|
||||
if _slow_timer > 0.0:
|
||||
_slow_timer -= delta
|
||||
if _slow_timer <= 0.0:
|
||||
_slow_factor = 1.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damage / death
|
||||
@@ -63,14 +85,4 @@ func _spawn_drop() -> void:
|
||||
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)
|
||||
_contact_timer = contact_cooldown
|
||||
+47
-91
@@ -1,87 +1,74 @@
|
||||
extends CharacterBody2D
|
||||
extends EnemyBase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# Snaps to the nearest wall on spawn and stays there forever.
|
||||
# Fires a rotating burst loop indefinitely — a stationary turret.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
# Room interior half-extents (960×540 room, 32px walls, 16px gap buffer)
|
||||
const INNER_HALF_X := 432.0
|
||||
const INNER_HALF_Y := 222.0
|
||||
|
||||
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 BURST_PAUSE := 1.8 # seconds after burst 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
|
||||
var _fire_angle: float = 0.0
|
||||
var _shot_count: int = 0
|
||||
var _shot_timer: float = 1.0 # initial delay before first burst
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
_fire_angle = randf() * TAU # random initial burst direction
|
||||
max_health = 30.0
|
||||
current_health = 30.0
|
||||
speed = 0.0
|
||||
contact_damage = 0.5
|
||||
super._ready()
|
||||
_fire_angle = randf() * TAU
|
||||
_snap_to_wall()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snap to the nearest wall face at spawn
|
||||
# ---------------------------------------------------------------------------
|
||||
func _snap_to_wall() -> 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))
|
||||
|
||||
var wall_pos: Vector2
|
||||
if md == d_left:
|
||||
wall_pos = room_center + Vector2(-INNER_HALF_X, lp.y)
|
||||
elif md == d_right:
|
||||
wall_pos = room_center + Vector2( INNER_HALF_X, lp.y)
|
||||
elif md == d_up:
|
||||
wall_pos = room_center + Vector2(lp.x, -INNER_HALF_Y)
|
||||
else:
|
||||
wall_pos = room_center + Vector2(lp.x, INNER_HALF_Y)
|
||||
|
||||
global_position = wall_pos
|
||||
|
||||
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)
|
||||
_tick_slow(delta)
|
||||
velocity = Vector2.ZERO
|
||||
_do_fire(delta)
|
||||
velocity *= _slow_factor
|
||||
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
|
||||
@@ -91,7 +78,6 @@ func _do_fire(delta: float) -> void:
|
||||
_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
|
||||
@@ -103,34 +89,4 @@ func _fire_projectile() -> void:
|
||||
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)
|
||||
get_parent().call_deferred("add_child", proj)
|
||||
@@ -1,4 +1,4 @@
|
||||
extends CharacterBody2D
|
||||
extends EnemyBase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The Supervisor — Phase 1/2/Climax boss
|
||||
@@ -9,14 +9,6 @@ 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,18 +38,20 @@ const WIND_UP_DURATION := 0.65 # seconds of warning before beam fires
|
||||
# ---------------------------------------------------------------------------
|
||||
const FREEZE_DURATION := 2.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 Drifter spawning — one at a time, over a fixed window
|
||||
# ---------------------------------------------------------------------------
|
||||
const DRIFTER_SPAWN_COUNT := 4 # total drifters spawned in phase 2
|
||||
const DRIFTER_SPAWN_INTERVAL := 8.0 # seconds between each spawn
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -65,21 +59,28 @@ 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 _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
|
||||
# Phase 2 Drifter spawning
|
||||
var _drifters_spawned: int = 0
|
||||
var _drifter_timer: float = DRIFTER_SPAWN_INTERVAL
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
var _freeze_timer: float = 0.0
|
||||
|
||||
# Facing indicator — Line2D child added to visual at runtime
|
||||
var _facing_indicator: Line2D = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ready
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
max_health = 200.0
|
||||
current_health = 200.0
|
||||
contact_damage = 1.0
|
||||
contact_cooldown = 0.8
|
||||
super._ready()
|
||||
var c := global_position
|
||||
_patrol_points = [
|
||||
c + Vector2(-PATROL_W, -PATROL_H),
|
||||
@@ -87,6 +88,23 @@ func _ready() -> void:
|
||||
c + Vector2( PATROL_W, PATROL_H),
|
||||
c + Vector2(-PATROL_W, PATROL_H),
|
||||
]
|
||||
_build_facing_indicator()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing indicator — arrow line attached to the visual node
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_facing_indicator() -> void:
|
||||
var arrow := Line2D.new()
|
||||
arrow.width = 3.0
|
||||
arrow.default_color = Color(1.0, 0.9, 0.3, 0.85)
|
||||
arrow.add_point(Vector2(0.0, 0.0))
|
||||
arrow.add_point(Vector2(22.0, 0.0))
|
||||
visual.add_child(arrow)
|
||||
_facing_indicator = arrow
|
||||
|
||||
func _update_facing(dir: Vector2) -> void:
|
||||
if _facing_indicator != null:
|
||||
_facing_indicator.rotation = dir.angle()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics loop
|
||||
@@ -100,12 +118,11 @@ func _physics_process(delta: float) -> void:
|
||||
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:
|
||||
elif _phase == Phase.TWO and current_health <= 5.0:
|
||||
_enter_climax()
|
||||
|
||||
match _phase:
|
||||
Phase.ONE:
|
||||
# Stop and lock on during wind-up
|
||||
if _winding_up:
|
||||
velocity = Vector2.ZERO
|
||||
else:
|
||||
@@ -115,25 +132,38 @@ func _physics_process(delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
else:
|
||||
_do_chase(PATROL_SPEED_P2)
|
||||
_maybe_spawn_drifter()
|
||||
_tick_drifter_spawns(delta)
|
||||
Phase.CLIMAX:
|
||||
_do_climax(delta)
|
||||
|
||||
move_and_slide()
|
||||
# Update facing indicator (hidden during climax)
|
||||
if _phase != Phase.CLIMAX:
|
||||
if _facing_indicator != null:
|
||||
_facing_indicator.visible = true
|
||||
var facing: Vector2
|
||||
if _winding_up:
|
||||
facing = _wind_up_dir
|
||||
elif velocity.length() > 1.0:
|
||||
facing = velocity.normalized()
|
||||
elif _player != null:
|
||||
facing = (_player.global_position - global_position).normalized()
|
||||
else:
|
||||
facing = Vector2.RIGHT
|
||||
_update_facing(facing)
|
||||
else:
|
||||
if _facing_indicator != null:
|
||||
_facing_indicator.visible = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
func _find_player() -> void:
|
||||
if _player == null or not is_instance_valid(_player):
|
||||
_player = get_tree().get_first_node_in_group("player")
|
||||
move_and_slide()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
func _enter_phase_two() -> void:
|
||||
_phase = Phase.TWO
|
||||
_winding_up = false
|
||||
_phase = Phase.TWO
|
||||
_winding_up = false
|
||||
_drifters_spawned = 0
|
||||
_drifter_timer = DRIFTER_SPAWN_INTERVAL * 0.5 # first spawn sooner
|
||||
|
||||
func _enter_climax() -> void:
|
||||
_phase = Phase.CLIMAX
|
||||
@@ -168,15 +198,18 @@ func _do_chase(spd: float) -> void:
|
||||
velocity = (_player.global_position - global_position).normalized() * spd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — spawn one Drifter
|
||||
# Phase 2 — spawn Drifters one at a time on a fixed interval
|
||||
# ---------------------------------------------------------------------------
|
||||
func _maybe_spawn_drifter() -> void:
|
||||
if _drifter_spawned:
|
||||
func _tick_drifter_spawns(delta: float) -> void:
|
||||
if _drifters_spawned >= DRIFTER_SPAWN_COUNT:
|
||||
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)
|
||||
_drifter_timer -= delta
|
||||
if _drifter_timer <= 0.0:
|
||||
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)
|
||||
_drifters_spawned += 1
|
||||
_drifter_timer = DRIFTER_SPAWN_INTERVAL
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Climax — frozen distress signal, then die
|
||||
@@ -196,7 +229,6 @@ 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:
|
||||
@@ -206,7 +238,6 @@ func _tick_beam(delta: float) -> void:
|
||||
_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:
|
||||
@@ -216,19 +247,16 @@ func _tick_beam(delta: float) -> void:
|
||||
_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
|
||||
_beam_timer = 0.3
|
||||
|
||||
func _player_in_sensor_cone() -> bool:
|
||||
if _player == null:
|
||||
@@ -243,7 +271,7 @@ 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 forward := _wind_up_dir
|
||||
var beam_len := BEAM_RANGE
|
||||
var beam_w := 30.0
|
||||
var offset := 24.0
|
||||
@@ -280,7 +308,7 @@ func _fire_beam() -> void:
|
||||
_beam_node = beam
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damage / death
|
||||
# Damage / death — override base to emit boss signals; no drop (room handles it)
|
||||
# ---------------------------------------------------------------------------
|
||||
func take_damage(amount: float) -> void:
|
||||
current_health -= amount
|
||||
@@ -293,47 +321,27 @@ 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)
|
||||
queue_free() # no drop — room.gd spawns the guaranteed body part
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# Visual — overrides base _tick_flash with phase-aware tints
|
||||
# ---------------------------------------------------------------------------
|
||||
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)
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
@@ -14,6 +14,7 @@ signal floor_cleared(floor_number: int)
|
||||
# ---------------------------------------------------------------------------
|
||||
signal room_entered(room_id: String)
|
||||
signal room_cleared(room_id: String)
|
||||
signal room_revealed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Items & Loot
|
||||
|
||||
@@ -38,7 +38,10 @@ func go_to_hub() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/hub/hub.tscn")
|
||||
|
||||
func start_run() -> void:
|
||||
RunManager.start_run(0, [], [])
|
||||
# Bring everything in the wallet into the run — it's on the player, so it's at risk
|
||||
var brought := UpgradeManager.wallet_credits
|
||||
UpgradeManager.wallet_credits = 0
|
||||
RunManager.start_run(brought, [], [])
|
||||
change_state(GameState.RUN)
|
||||
get_tree().change_scene_to_file("res://scenes/run/floor.tscn")
|
||||
|
||||
|
||||
+16
-11
@@ -9,21 +9,22 @@ const ROOM_H := 540.0
|
||||
const TRANSITION_TIME := 0.25
|
||||
const ENTRY_INSET := 60.0
|
||||
|
||||
# Fixed hub layout — 6 rooms arranged as:
|
||||
# Fixed hub layout — 7 rooms arranged as:
|
||||
#
|
||||
# [Control (0,-1)]
|
||||
# |
|
||||
# [Cafe(-1,0)]-[Staging(0,0)]-[Armory(1,0)]
|
||||
# | |
|
||||
# [Shop(0,1)]----[Trophy(1,1)]
|
||||
# [Control(-1,-1)]
|
||||
# |
|
||||
# [Armory(-1,0)]-[Staging(0,0)]-[Shop(1,0)]
|
||||
# | | |
|
||||
# [Practice(-1,1)]-[Cafeteria(0,1)]-[Trophy(1,1)]
|
||||
#
|
||||
# HubRoomType: STAGING=0, ARMORY=1, CAFETERIA=2, SHOP=3, CONTROL_ROOM=4, TROPHY_ROOM=5
|
||||
# HubRoomType: STAGING=0, ARMORY=1, CAFETERIA=2, SHOP=3, CONTROL_ROOM=4, TROPHY_ROOM=5, PRACTICE=6
|
||||
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": 4, "grid": Vector2i(-1, -1)}, # CONTROL_ROOM
|
||||
{"type": 1, "grid": Vector2i(-1, 0)}, # ARMORY
|
||||
{"type": 3, "grid": Vector2i( 1, 0)}, # SHOP
|
||||
{"type": 6, "grid": Vector2i(-1, 1)}, # PRACTICE
|
||||
{"type": 2, "grid": Vector2i( 0, 1)}, # CAFETERIA
|
||||
{"type": 5, "grid": Vector2i( 1, 1)}, # TROPHY_ROOM
|
||||
]
|
||||
|
||||
@@ -41,6 +42,7 @@ const DIRS := {
|
||||
@onready var camera: Camera2D = $Camera2D
|
||||
@onready var player: Node2D = $Player
|
||||
@onready var armory_ui: CanvasLayer = $ArmoryUI
|
||||
@onready var atm_ui: CanvasLayer = $AtmUI
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
@@ -131,3 +133,6 @@ func _grid_to_world(grid_pos: Vector2i) -> Vector2:
|
||||
# ---------------------------------------------------------------------------
|
||||
func open_armory() -> void:
|
||||
armory_ui.open()
|
||||
|
||||
func open_atm() -> void:
|
||||
atm_ui.open()
|
||||
|
||||
@@ -11,6 +11,7 @@ enum HubRoomType {
|
||||
SHOP = 3,
|
||||
CONTROL_ROOM = 4,
|
||||
TROPHY_ROOM = 5,
|
||||
PRACTICE = 6,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -31,6 +32,7 @@ const ROOM_NAMES := [
|
||||
"SHOP", # 3
|
||||
"CONTROL ROOM", # 4
|
||||
"TROPHY ROOM", # 5
|
||||
"PRACTICE ROOM", # 6
|
||||
]
|
||||
|
||||
const FLOOR_COLORS := [
|
||||
@@ -40,6 +42,7 @@ const FLOOR_COLORS := [
|
||||
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
|
||||
Color(0.12, 0.16, 0.13), # PRACTICE — muted green
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -73,8 +76,11 @@ func setup(room_type: int, connections: Dictionary, hub_ref: Node) -> void:
|
||||
|
||||
if _type == HubRoomType.STAGING:
|
||||
_build_run_portal()
|
||||
_build_atm()
|
||||
elif _type == HubRoomType.ARMORY:
|
||||
_build_armory_workbench()
|
||||
elif _type == HubRoomType.PRACTICE:
|
||||
_build_practice_room()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wall generation (same logic as room.gd)
|
||||
@@ -256,3 +262,50 @@ func _build_run_portal() -> void:
|
||||
if b.is_in_group("player"):
|
||||
GameManager.call_deferred("start_run"))
|
||||
contents.add_child(area)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deposit machine — shows bank balance, lets player set withdrawal for next run
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_atm() -> void:
|
||||
# Visual — grey terminal with cyan screen
|
||||
var body := Polygon2D.new()
|
||||
body.polygon = PackedVector2Array([-28, -40, 28, -40, 28, 40, -28, 40])
|
||||
body.color = Color(0.25, 0.28, 0.32)
|
||||
body.position = Vector2(200, -150)
|
||||
contents.add_child(body)
|
||||
|
||||
var screen := Polygon2D.new()
|
||||
screen.polygon = PackedVector2Array([-18, -28, 18, -28, 18, 0, -18, 0])
|
||||
screen.color = Color(0.1, 0.8, 0.9, 0.85)
|
||||
screen.position = Vector2(200, -150)
|
||||
contents.add_child(screen)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "DEPOSIT"
|
||||
lbl.position = Vector2(172, -202)
|
||||
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(56, 80)
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
area.position = Vector2(200, -150)
|
||||
|
||||
area.body_entered.connect(func(b: Node) -> void:
|
||||
if b.is_in_group("player"):
|
||||
_hub.call_deferred("open_atm"))
|
||||
contents.add_child(area)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Practice room — placeholder until VS combat is implemented
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_practice_room() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = "PRACTICE ROOM — OFFLINE"
|
||||
lbl.position = Vector2(-90, -20)
|
||||
contents.add_child(lbl)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
extends Area2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shop Item Pedestal
|
||||
# Player walks up → prompt appears → press E to buy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var item: RunItem = null
|
||||
var price: int = 20
|
||||
|
||||
var _sold: bool = false
|
||||
var _player_in_range: bool = false
|
||||
var _prompt_label: Label = null
|
||||
var _info_label: Label = null
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = 0
|
||||
collision_mask = 2 # player layer
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := CircleShape2D.new()
|
||||
rs.radius = 36.0
|
||||
cs.shape = rs
|
||||
add_child(cs)
|
||||
|
||||
body_entered.connect(_on_body_entered)
|
||||
body_exited.connect(_on_body_exited)
|
||||
|
||||
# Item name + price above pedestal
|
||||
_info_label = Label.new()
|
||||
_info_label.text = "%s\n%d SCRAP" % [item.display_name if item else "???", price]
|
||||
_info_label.position = Vector2(-48, -56)
|
||||
add_child(_info_label)
|
||||
|
||||
# Buy prompt — hidden until player is in range
|
||||
_prompt_label = Label.new()
|
||||
_prompt_label.text = "[E] Buy"
|
||||
_prompt_label.position = Vector2(-20, -76)
|
||||
_prompt_label.modulate = Color(1.0, 0.9, 0.3)
|
||||
_prompt_label.visible = false
|
||||
add_child(_prompt_label)
|
||||
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
# Pedestal base
|
||||
var col := Color(0.25, 0.25, 0.25) if _sold else Color(0.85, 0.70, 0.10)
|
||||
draw_rect(Rect2(-14, -14, 28, 28), col)
|
||||
# Sold X
|
||||
if _sold:
|
||||
draw_line(Vector2(-10, -10), Vector2(10, 10), Color(0.5, 0.5, 0.5), 2.0)
|
||||
draw_line(Vector2(10, -10), Vector2(-10, 10), Color(0.5, 0.5, 0.5), 2.0)
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _sold or not _player_in_range:
|
||||
return
|
||||
if event.is_action_pressed("ui_accept"):
|
||||
_attempt_purchase()
|
||||
return
|
||||
if event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
if key.pressed and key.physical_keycode == KEY_E:
|
||||
_attempt_purchase()
|
||||
|
||||
func _attempt_purchase() -> void:
|
||||
if RunManager.run_credits < price:
|
||||
_flash_no_funds()
|
||||
return
|
||||
|
||||
RunManager.spend_credits(price)
|
||||
|
||||
var player := get_tree().get_first_node_in_group("player") as Node2D
|
||||
if player == null:
|
||||
return
|
||||
|
||||
RunManager.collect_item(item)
|
||||
if item.heal_on_pickup > 0.0:
|
||||
player.stats.heal(item.heal_on_pickup)
|
||||
player.stats.recalculate()
|
||||
EventBus.player_health_changed.emit(player.stats.current_health, player.stats.max_health)
|
||||
|
||||
_sold = true
|
||||
_player_in_range = false
|
||||
if _prompt_label:
|
||||
_prompt_label.visible = false
|
||||
if _info_label:
|
||||
_info_label.text = "%s\n[SOLD]" % (item.display_name if item else "???")
|
||||
queue_redraw()
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and not _sold:
|
||||
_player_in_range = true
|
||||
if _prompt_label:
|
||||
_prompt_label.visible = true
|
||||
|
||||
func _on_body_exited(body: Node) -> void:
|
||||
if body.is_in_group("player"):
|
||||
_player_in_range = false
|
||||
if _prompt_label:
|
||||
_prompt_label.visible = false
|
||||
|
||||
func _flash_no_funds() -> void:
|
||||
if _prompt_label == null:
|
||||
return
|
||||
_prompt_label.text = "Need %d SCRAP" % price
|
||||
_prompt_label.modulate = Color(1.0, 0.2, 0.2)
|
||||
await get_tree().create_timer(1.2).timeout
|
||||
if is_instance_valid(self) and not _sold:
|
||||
_prompt_label.text = "[E] Buy"
|
||||
_prompt_label.modulate = Color(1.0, 0.9, 0.3)
|
||||
+141
-8
@@ -9,12 +9,29 @@ extends CharacterBody2D
|
||||
|
||||
const PROJECTILE = preload("res://scenes/player/projectile.tscn")
|
||||
const IFRAME_DURATION := 0.8
|
||||
const SHIELD_COOLDOWN := 3.0
|
||||
|
||||
var _shoot_timer: float = 0.0
|
||||
var _shoot_timer: float = 0.0
|
||||
var _iframe_timer: float = 0.0
|
||||
|
||||
# Overclock — counts shots fired; every 3rd deals 0 damage
|
||||
var _overclock_counter: int = 0
|
||||
|
||||
# Memory Spike — first projectile per room deals 3x damage
|
||||
var _memory_spike_available: bool = true
|
||||
|
||||
# Shield Projector — active block, absorbs one hit
|
||||
var _shield_ready: bool = true
|
||||
var _shield_active: bool = false
|
||||
var _shield_cd_timer: float = 0.0
|
||||
|
||||
# Oil slick — count of slick zones currently overlapping player
|
||||
var _slick_count: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("player")
|
||||
EventBus.room_entered.connect(_on_room_entered)
|
||||
EventBus.room_cleared.connect(_on_room_cleared)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics
|
||||
@@ -23,6 +40,7 @@ func _physics_process(delta: float) -> void:
|
||||
_handle_movement()
|
||||
_handle_shooting(delta)
|
||||
_handle_iframes(delta)
|
||||
_handle_shield(delta)
|
||||
move_and_slide()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -36,7 +54,12 @@ func _handle_movement() -> void:
|
||||
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 _slick_count > 0:
|
||||
# Oil slick — momentum-based: velocity slowly drifts toward desired direction
|
||||
velocity = velocity.lerp(input_dir * stats.speed, 0.08)
|
||||
else:
|
||||
velocity = input_dir * stats.speed
|
||||
|
||||
if input_dir.x != 0.0:
|
||||
body.scale.x = sign(input_dir.x)
|
||||
@@ -62,27 +85,62 @@ func _handle_shooting(delta: float) -> void:
|
||||
_shoot_timer = 1.0 / stats.fire_rate
|
||||
|
||||
func _fire(direction: Vector2) -> void:
|
||||
# Overclock: every 3rd shot deals 0 damage
|
||||
var is_zeroed_shot := false
|
||||
if stats.overclock:
|
||||
_overclock_counter += 1
|
||||
if _overclock_counter >= 3:
|
||||
_overclock_counter = 0
|
||||
is_zeroed_shot = true
|
||||
|
||||
if stats.scatter_count <= 1:
|
||||
_spawn_projectile(direction)
|
||||
_spawn_projectile(direction, is_zeroed_shot)
|
||||
else:
|
||||
var spread := deg_to_rad(stats.scatter_spread_deg)
|
||||
var step := spread / float(stats.scatter_count - 1)
|
||||
var step := spread / float(stats.scatter_count - 1) if stats.scatter_count > 1 else 0.0
|
||||
var start := direction.angle() - spread / 2.0
|
||||
for i in stats.scatter_count:
|
||||
_spawn_projectile(Vector2.from_angle(start + step * i))
|
||||
_spawn_projectile(Vector2.from_angle(start + step * i), is_zeroed_shot)
|
||||
|
||||
func _spawn_projectile(dir: Vector2) -> void:
|
||||
func _spawn_projectile(dir: Vector2, zero_damage: bool = false) -> void:
|
||||
var proj: Area2D = PROJECTILE.instantiate()
|
||||
proj.global_position = shoot_origin.global_position
|
||||
proj.direction = dir
|
||||
proj.damage = stats.damage
|
||||
proj.speed = stats.proj_speed
|
||||
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
|
||||
|
||||
if zero_damage:
|
||||
proj.damage = 0.0
|
||||
elif stats.memory_spike and _memory_spike_available:
|
||||
proj.damage = stats.damage * 3.0
|
||||
_memory_spike_available = false
|
||||
else:
|
||||
proj.damage = stats.damage
|
||||
|
||||
get_parent().add_child(proj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shield Projector — Space to activate; absorbs one hit, SHIELD_COOLDOWN
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_shield(delta: float) -> void:
|
||||
if not stats.shield_projector:
|
||||
_shield_active = false
|
||||
_shield_ready = true
|
||||
_shield_cd_timer = 0.0
|
||||
return
|
||||
|
||||
if _shield_cd_timer > 0.0:
|
||||
_shield_cd_timer -= delta
|
||||
if _shield_cd_timer <= 0.0:
|
||||
_shield_ready = true
|
||||
|
||||
if _shield_ready and Input.is_physical_key_pressed(KEY_SPACE):
|
||||
_shield_active = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Iframes — flash body during invincibility
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,11 +158,86 @@ func _handle_iframes(delta: float) -> void:
|
||||
func take_damage(amount: float) -> void:
|
||||
if _iframe_timer > 0.0:
|
||||
return
|
||||
stats.current_health -= amount
|
||||
|
||||
# Shield Projector — absorb one hit
|
||||
if _shield_active:
|
||||
_shield_active = false
|
||||
_shield_ready = false
|
||||
_shield_cd_timer = SHIELD_COOLDOWN
|
||||
return
|
||||
|
||||
# Rust Coat — reduce damage (min 1)
|
||||
var actual := amount
|
||||
if stats.rust_coat:
|
||||
actual = maxf(actual - stats.damage_reduction, 1.0)
|
||||
|
||||
stats.current_health -= actual
|
||||
_iframe_timer = IFRAME_DURATION
|
||||
EventBus.player_health_changed.emit(stats.current_health, stats.max_health)
|
||||
|
||||
# Reactive effects on taking damage
|
||||
if stats.static_discharge:
|
||||
_trigger_static_discharge()
|
||||
if stats.coolant_leak:
|
||||
_spawn_coolant_zone()
|
||||
|
||||
if stats.current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
RunManager.end_run(false)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static Discharge — instant AoE damage to enemies in radius
|
||||
# ---------------------------------------------------------------------------
|
||||
func _trigger_static_discharge() -> void:
|
||||
for enemy in get_tree().get_nodes_in_group("enemies"):
|
||||
if not is_instance_valid(enemy):
|
||||
continue
|
||||
if global_position.distance_to(enemy.global_position) <= stats.discharge_radius:
|
||||
if enemy.has_method("take_damage"):
|
||||
enemy.take_damage(stats.discharge_damage)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coolant Leak — spawn a temporary slow zone at player position (1s)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _spawn_coolant_zone() -> void:
|
||||
var zone := Area2D.new()
|
||||
zone.collision_layer = 0
|
||||
zone.collision_mask = 8 # enemy CharacterBody2D layer
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var sh := CircleShape2D.new()
|
||||
sh.radius = 24.0
|
||||
cs.shape = sh
|
||||
zone.add_child(cs)
|
||||
zone.position = (get_parent() as Node2D).to_local(global_position)
|
||||
|
||||
zone.body_entered.connect(func(b: Node) -> void:
|
||||
if b.has_method("apply_slow"):
|
||||
b.apply_slow(2.0, 0.4))
|
||||
|
||||
get_parent().call_deferred("add_child", zone)
|
||||
get_tree().create_timer(1.0).timeout.connect(func() -> void:
|
||||
if is_instance_valid(zone):
|
||||
zone.queue_free())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Oil slick — called by hazard Area2D nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
func enter_slick() -> void:
|
||||
_slick_count += 1
|
||||
|
||||
func exit_slick() -> void:
|
||||
_slick_count = maxi(_slick_count - 1, 0)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Room event handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_room_entered(_room_id: String) -> void:
|
||||
_memory_spike_available = true
|
||||
_overclock_counter = 0
|
||||
|
||||
func _on_room_cleared(_room_id: String) -> void:
|
||||
if stats.fragmented_map:
|
||||
EventBus.room_revealed.emit()
|
||||
@@ -55,6 +55,8 @@ func _ready() -> void:
|
||||
if RunManager.player_health_carry > 0.0:
|
||||
current_health = minf(RunManager.player_health_carry, max_health)
|
||||
RunManager.player_health_carry = -1.0
|
||||
else:
|
||||
current_health = max_health # fresh run — start at full HP
|
||||
|
||||
func recalculate() -> void:
|
||||
speed = BASE_SPEED + UpgradeManager.get_stat_modifier("speed")
|
||||
@@ -122,13 +124,13 @@ func _apply_run_items() -> void:
|
||||
fragmented_map = true
|
||||
RunItem.Effect.OVERCLOCK:
|
||||
overclock = true
|
||||
fire_rate *= 1.4
|
||||
fire_rate *= 1.6
|
||||
|
||||
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)
|
||||
var part: BodyPartData = UpgradeManager.get_equipped_part(slot) as BodyPartData
|
||||
if part == null:
|
||||
continue
|
||||
if part.shield_projector:
|
||||
|
||||
+182
-11
@@ -21,22 +21,28 @@ 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 SHOP_PEDESTAL_SCR = preload("res://scripts/items/shop_item_pedestal.gd")
|
||||
|
||||
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",
|
||||
"res://data/body_parts/head_wide_angle_lens.tres",
|
||||
"res://data/body_parts/head_targeting_spike.tres",
|
||||
"res://data/body_parts/torso_reinforced_chassis.tres",
|
||||
"res://data/body_parts/torso_lightweight_frame.tres",
|
||||
"res://data/body_parts/left_arm_scatter_emitter.tres",
|
||||
"res://data/body_parts/left_arm_shield_projector.tres",
|
||||
"res://data/body_parts/right_arm_heavy_emitter.tres",
|
||||
"res://data/body_parts/right_arm_rapid_emitter.tres",
|
||||
]
|
||||
|
||||
const ALL_ITEM_PATHS: Array[String] = [
|
||||
"res://data/run_items/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",
|
||||
"res://data/run_items/coolant_leak.tres",
|
||||
"res://data/run_items/overclock_module.tres",
|
||||
"res://data/run_items/scrap_magnet.tres",
|
||||
"res://data/run_items/memory_spike.tres",
|
||||
"res://data/run_items/rust_coat.tres",
|
||||
"res://data/run_items/static_discharge.tres",
|
||||
"res://data/run_items/fragmented_map.tres",
|
||||
"res://data/run_items/patch_kit.tres",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -69,6 +75,7 @@ func setup(room_data: RoomData, floor_ref: Node) -> void:
|
||||
# Activate — called on first (and repeat) entry
|
||||
# ---------------------------------------------------------------------------
|
||||
func activate() -> void:
|
||||
EventBus.room_entered.emit(data.id if "id" in data else "")
|
||||
if data.visited:
|
||||
_update_door_locks()
|
||||
return
|
||||
@@ -82,6 +89,9 @@ func activate() -> void:
|
||||
RoomData.RoomType.ITEM:
|
||||
_spawn_run_item()
|
||||
data.cleared = true
|
||||
RoomData.RoomType.SHOP:
|
||||
_spawn_shop()
|
||||
data.cleared = true
|
||||
_:
|
||||
data.cleared = true
|
||||
|
||||
@@ -232,6 +242,9 @@ func _get_enemy_count() -> int:
|
||||
_: return randi_range(2, 3)
|
||||
|
||||
func _spawn_enemies() -> void:
|
||||
# Hazards first — added to contents before enemies so they draw underneath
|
||||
_spawn_hazards()
|
||||
|
||||
var hw := ROOM_W / 2.0 - SPAWN_MARGIN
|
||||
var hh := ROOM_H / 2.0 - SPAWN_MARGIN
|
||||
var pool := _get_enemy_pool()
|
||||
@@ -263,6 +276,7 @@ func _on_enemy_died() -> void:
|
||||
_spawn_body_part_drop()
|
||||
_spawn_floor_exit()
|
||||
room_cleared.emit()
|
||||
EventBus.room_cleared.emit(data.id if "id" in data else "")
|
||||
_update_door_locks()
|
||||
|
||||
|
||||
@@ -321,6 +335,163 @@ func _spawn_run_item() -> void:
|
||||
pickup.position = Vector2.ZERO
|
||||
contents.add_child(pickup)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environmental hazards — spawned in combat rooms
|
||||
# ---------------------------------------------------------------------------
|
||||
func _spawn_hazards() -> void:
|
||||
# Oil slicks — 0-2 random puddles in the room interior
|
||||
var hw := ROOM_W / 2.0 - SPAWN_MARGIN
|
||||
var hh := ROOM_H / 2.0 - SPAWN_MARGIN
|
||||
for i in randi_range(0, 2):
|
||||
_make_oil_slick(Vector2(randf_range(-hw, hw), randf_range(-hh, hh)))
|
||||
|
||||
# Exposed wiring — 0-2 sections along walls
|
||||
for i in randi_range(0, 2):
|
||||
_make_exposed_wiring()
|
||||
|
||||
func _make_oil_slick(pos: Vector2) -> void:
|
||||
var slick := Area2D.new()
|
||||
slick.collision_layer = 0
|
||||
slick.collision_mask = 2 | 8 # player (2) + enemies (8)
|
||||
slick.position = pos
|
||||
|
||||
var radius: float = randf_range(44.0, 68.0)
|
||||
var cs := CollisionShape2D.new()
|
||||
var sh := CircleShape2D.new()
|
||||
sh.radius = radius
|
||||
cs.shape = sh
|
||||
slick.add_child(cs)
|
||||
|
||||
# Visual — flattened dark ellipse (puddle)
|
||||
var poly := Polygon2D.new()
|
||||
var pts := PackedVector2Array()
|
||||
var segs := 14
|
||||
for i in segs:
|
||||
var a := i * TAU / segs
|
||||
pts.append(Vector2(cos(a) * radius, sin(a) * radius * 0.55))
|
||||
poly.polygon = pts
|
||||
poly.color = Color(0.04, 0.07, 0.14, 0.78)
|
||||
slick.add_child(poly)
|
||||
|
||||
slick.body_entered.connect(func(b: Node) -> void:
|
||||
if b.is_in_group("player") and b.has_method("enter_slick"):
|
||||
b.enter_slick()
|
||||
elif b.is_in_group("enemies") and b.has_method("apply_slow"):
|
||||
b.apply_slow(999.0, 0.35))
|
||||
slick.body_exited.connect(func(b: Node) -> void:
|
||||
if b.is_in_group("player") and b.has_method("exit_slick"):
|
||||
b.exit_slick()
|
||||
elif b.is_in_group("enemies") and b.has_method("apply_slow"):
|
||||
b.apply_slow(0.0, 1.0)) # clear slow immediately
|
||||
|
||||
contents.add_child(slick)
|
||||
|
||||
func _make_exposed_wiring() -> void:
|
||||
var hw := ROOM_W / 2.0 - WALL_T
|
||||
var hh := ROOM_H / 2.0 - WALL_T
|
||||
var wire_len := randf_range(80.0, 150.0)
|
||||
var wire_thick := 14.0
|
||||
var inset := wire_thick / 2.0 + 2.0
|
||||
var half := wire_len / 2.0
|
||||
var hdw := DOOR_W / 2.0 # 40 — half door gap on horiz walls
|
||||
var hdh := DOOR_H / 2.0 # 40 — half door gap on vert walls
|
||||
|
||||
# Build candidates: {wall, min, max} for center position along the wall.
|
||||
# Door walls get two segments (one each side of gap); non-door walls get one full segment.
|
||||
var wall_dirs := ["up", "down", "left", "right"]
|
||||
var candidates : Array = []
|
||||
for wall_id in 4:
|
||||
var has_door := data.connections.has(wall_dirs[wall_id])
|
||||
var is_horiz := wall_id <= 1
|
||||
var extent := hw if is_horiz else hh
|
||||
var door_half := hdw if is_horiz else hdh
|
||||
if has_door:
|
||||
var a_min := -extent + half; var a_max := -door_half - half
|
||||
var b_min := door_half + half; var b_max := extent - half
|
||||
if a_min <= a_max: candidates.append({"wall": wall_id, "min": a_min, "max": a_max})
|
||||
if b_min <= b_max: candidates.append({"wall": wall_id, "min": b_min, "max": b_max})
|
||||
else:
|
||||
var s_min := -extent + half; var s_max := extent - half
|
||||
if s_min <= s_max: candidates.append({"wall": wall_id, "min": s_min, "max": s_max})
|
||||
|
||||
if candidates.is_empty():
|
||||
return
|
||||
|
||||
var chosen: Dictionary = candidates[randi() % candidates.size()]
|
||||
var wall := chosen["wall"] as int
|
||||
var along := randf_range(chosen["min"], chosen["max"])
|
||||
|
||||
var pos: Vector2
|
||||
var size: Vector2
|
||||
match wall:
|
||||
0: pos = Vector2(along, -hh + inset); size = Vector2(wire_len, wire_thick)
|
||||
1: pos = Vector2(along, hh - inset); size = Vector2(wire_len, wire_thick)
|
||||
2: pos = Vector2(-hw + inset, along); size = Vector2(wire_thick, wire_len)
|
||||
_: pos = Vector2( hw - inset, along); size = Vector2(wire_thick, wire_len)
|
||||
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2 # player layer
|
||||
area.position = pos
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = size
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
|
||||
# Visual — bright yellow-orange strip
|
||||
var poly := Polygon2D.new()
|
||||
var hx := size.x / 2.0
|
||||
var hy := size.y / 2.0
|
||||
poly.polygon = PackedVector2Array([
|
||||
Vector2(-hx, -hy), Vector2(hx, -hy),
|
||||
Vector2( hx, hy), Vector2(-hx, hy),
|
||||
])
|
||||
poly.color = Color(1.0, 0.85, 0.05, 0.90)
|
||||
area.add_child(poly)
|
||||
|
||||
# Damage on contact — player iframes act as natural cooldown
|
||||
area.body_entered.connect(func(b: Node) -> void:
|
||||
if b.is_in_group("player") and b.has_method("take_damage"):
|
||||
b.take_damage(1.0))
|
||||
|
||||
contents.add_child(area)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shop spawning — 2 random items + 1 Patch Kit, spaced horizontally
|
||||
# ---------------------------------------------------------------------------
|
||||
func _spawn_shop() -> void:
|
||||
# Item slot paths (exclude Patch Kit from the random pool)
|
||||
const PATCH_KIT_PATH := "res://data/run_items/patch_kit.tres"
|
||||
var item_pool: Array[String] = []
|
||||
for p in ALL_ITEM_PATHS:
|
||||
if p != PATCH_KIT_PATH:
|
||||
item_pool.append(p)
|
||||
item_pool.shuffle()
|
||||
|
||||
# Positions: left item, center item, right = Patch Kit
|
||||
var positions: Array[Vector2] = [
|
||||
Vector2(-200, 0),
|
||||
Vector2( 0, 0),
|
||||
Vector2( 200, 0),
|
||||
]
|
||||
|
||||
var chosen: Array[String] = [
|
||||
item_pool[0],
|
||||
item_pool[1],
|
||||
PATCH_KIT_PATH,
|
||||
]
|
||||
var prices: Array[int] = [20, 20, 10]
|
||||
|
||||
for i in 3:
|
||||
var pedestal := Area2D.new()
|
||||
pedestal.set_script(SHOP_PEDESTAL_SCR)
|
||||
pedestal.position = positions[i]
|
||||
pedestal.set("item", load(chosen[i]))
|
||||
pedestal.set("price", prices[i])
|
||||
contents.add_child(pedestal)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Door locking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+27
-4
@@ -8,8 +8,11 @@ var run_active: bool = false
|
||||
var run_items: Array[Resource] = []
|
||||
var card_packs_found: Array[Resource] = []
|
||||
|
||||
# --- Brought into the run (lost on death) ---
|
||||
# --- Credits (brought in + earned in run — lost on death, returned on success) ---
|
||||
var run_credits: int = 0 # total spendable during this run
|
||||
var credits_brought_in: int = 0
|
||||
|
||||
# --- Brought into the run (lost on death) ---
|
||||
var buff_items_brought: Array[Resource] = []
|
||||
var npc_ids_on_run: Array[String] = []
|
||||
|
||||
@@ -24,8 +27,10 @@ func start_run(brought_credits: int, brought_buffs: Array[Resource], brought_npc
|
||||
run_items.clear()
|
||||
card_packs_found.clear()
|
||||
credits_brought_in = brought_credits
|
||||
run_credits = brought_credits
|
||||
buff_items_brought = brought_buffs.duplicate()
|
||||
npc_ids_on_run = brought_npc_ids.duplicate()
|
||||
EventBus.credits_changed.emit(run_credits)
|
||||
EventBus.run_started.emit()
|
||||
|
||||
func end_run(player_survived: bool) -> void:
|
||||
@@ -48,12 +53,29 @@ func collect_card_pack(pack: Resource) -> void:
|
||||
card_packs_found.append(pack)
|
||||
EventBus.card_pack_found.emit(pack)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credit management — called by currency_orb and shop_item_pedestal
|
||||
# ---------------------------------------------------------------------------
|
||||
func earn_credits(amount: int) -> void:
|
||||
run_credits += amount
|
||||
EventBus.credits_changed.emit(run_credits)
|
||||
|
||||
func spend_credits(amount: int) -> void:
|
||||
run_credits = maxi(run_credits - amount, 0)
|
||||
EventBus.credits_changed.emit(run_credits)
|
||||
|
||||
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
|
||||
# Return all remaining run credits to the player's wallet (on hand)
|
||||
UpgradeManager.wallet_credits += run_credits
|
||||
run_credits = 0
|
||||
# Run items are non-permanent — clear them (body parts stay via UpgradeManager)
|
||||
run_items.clear()
|
||||
card_packs_found.clear()
|
||||
# Health resets on hub return — do not carry it over
|
||||
player_health_carry = -1.0
|
||||
# NPCs survive
|
||||
for npc_id in npc_ids_on_run:
|
||||
NPCManager.return_npc_to_hub(npc_id)
|
||||
@@ -62,10 +84,11 @@ func _apply_run_death() -> void:
|
||||
# Everything brought in / found is lost
|
||||
run_items.clear()
|
||||
card_packs_found.clear()
|
||||
run_credits = 0
|
||||
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()
|
||||
EventBus.player_died.emit()
|
||||
@@ -5,7 +5,8 @@ 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("upgrades", "hub_credits", UpgradeManager.hub_credits)
|
||||
data.set_value("upgrades", "wallet_credits", UpgradeManager.wallet_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())
|
||||
@@ -17,7 +18,8 @@ func load_save() -> void:
|
||||
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)
|
||||
UpgradeManager.hub_credits = data.get_value("upgrades", "hub_credits", 0)
|
||||
UpgradeManager.wallet_credits = data.get_value("upgrades", "wallet_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", []))
|
||||
|
||||
@@ -164,4 +164,5 @@ func _equip_selected() -> void:
|
||||
return
|
||||
var part: BodyPartData = _items[_cursor]
|
||||
UpgradeManager.equip_part(part)
|
||||
SaveManager.save()
|
||||
_refresh_labels()
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ATM / Deposit Machine UI
|
||||
#
|
||||
# BANK — safe storage, never lost on death (hub_credits)
|
||||
# ON HAND — scrap the player is currently carrying (wallet_credits)
|
||||
#
|
||||
# DEPOSIT moves ON HAND → BANK
|
||||
# WITHDRAW moves BANK → ON HAND
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _bank_label: Label = null
|
||||
var _hand_label: Label = null
|
||||
var _amount_label: Label = null
|
||||
var _custom_input: LineEdit = null
|
||||
|
||||
var _transfer_amount: int = 0
|
||||
|
||||
const STEP_SMALL := 10
|
||||
const STEP_LARGE := 100
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 9
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_build_ui()
|
||||
visible = false
|
||||
|
||||
func _build_ui() -> void:
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0, 0, 0, 0.65)
|
||||
bg.anchor_right = 1.0
|
||||
bg.anchor_bottom = 1.0
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(bg)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.anchor_left = 0.5
|
||||
panel.anchor_top = 0.5
|
||||
panel.anchor_right = 0.5
|
||||
panel.anchor_bottom = 0.5
|
||||
panel.offset_left = -200.0
|
||||
panel.offset_top = -160.0
|
||||
panel.offset_right = 200.0
|
||||
panel.offset_bottom = 160.0
|
||||
bg.add_child(panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(vbox)
|
||||
|
||||
# Title
|
||||
var title := Label.new()
|
||||
title.text = "SCRAP DEPOSIT MACHINE"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(title)
|
||||
|
||||
vbox.add_child(HSeparator.new())
|
||||
|
||||
# Balance display — two rows
|
||||
_bank_label = Label.new()
|
||||
_bank_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(_bank_label)
|
||||
|
||||
_hand_label = Label.new()
|
||||
_hand_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(_hand_label)
|
||||
|
||||
vbox.add_child(HSeparator.new())
|
||||
|
||||
# Transfer amount row
|
||||
var amt_row := HBoxContainer.new()
|
||||
amt_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
amt_row.add_theme_constant_override("separation", 6)
|
||||
vbox.add_child(amt_row)
|
||||
|
||||
var amt_lbl := Label.new()
|
||||
amt_lbl.text = "AMOUNT:"
|
||||
amt_row.add_child(amt_lbl)
|
||||
|
||||
_add_btn(amt_row, "-100", func(): _adjust_amount(-STEP_LARGE))
|
||||
_add_btn(amt_row, "-10", func(): _adjust_amount(-STEP_SMALL))
|
||||
|
||||
_amount_label = Label.new()
|
||||
_amount_label.custom_minimum_size = Vector2(80, 0)
|
||||
_amount_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
amt_row.add_child(_amount_label)
|
||||
|
||||
_add_btn(amt_row, "+10", func(): _adjust_amount(STEP_SMALL))
|
||||
_add_btn(amt_row, "+100", func(): _adjust_amount(STEP_LARGE))
|
||||
|
||||
# Custom input row
|
||||
var custom_row := HBoxContainer.new()
|
||||
custom_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
custom_row.add_theme_constant_override("separation", 6)
|
||||
vbox.add_child(custom_row)
|
||||
|
||||
_custom_input = LineEdit.new()
|
||||
_custom_input.placeholder_text = "Custom amount..."
|
||||
_custom_input.custom_minimum_size = Vector2(140, 0)
|
||||
_custom_input.text_submitted.connect(_on_custom_submitted)
|
||||
custom_row.add_child(_custom_input)
|
||||
|
||||
_add_btn(custom_row, "SET", func(): _on_custom_submitted(_custom_input.text))
|
||||
|
||||
vbox.add_child(HSeparator.new())
|
||||
|
||||
# Deposit / Withdraw row
|
||||
var action_row := HBoxContainer.new()
|
||||
action_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
action_row.add_theme_constant_override("separation", 16)
|
||||
vbox.add_child(action_row)
|
||||
|
||||
var dep_btn := Button.new()
|
||||
dep_btn.text = "DEPOSIT\n(ON HAND → BANK)"
|
||||
dep_btn.pressed.connect(_on_deposit)
|
||||
action_row.add_child(dep_btn)
|
||||
|
||||
var wth_btn := Button.new()
|
||||
wth_btn.text = "WITHDRAW\n(BANK → ON HAND)"
|
||||
wth_btn.pressed.connect(_on_withdraw)
|
||||
action_row.add_child(wth_btn)
|
||||
|
||||
# Close
|
||||
var close_btn := Button.new()
|
||||
close_btn.text = "CLOSE"
|
||||
close_btn.pressed.connect(close)
|
||||
vbox.add_child(close_btn)
|
||||
|
||||
func _add_btn(parent: Node, text: String, cb: Callable) -> void:
|
||||
var b := Button.new()
|
||||
b.text = text
|
||||
b.pressed.connect(cb)
|
||||
parent.add_child(b)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Open / close
|
||||
# ---------------------------------------------------------------------------
|
||||
func open() -> void:
|
||||
_transfer_amount = 0
|
||||
_custom_input.text = ""
|
||||
_refresh()
|
||||
visible = true
|
||||
|
||||
func close() -> void:
|
||||
visible = false
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if visible and event.is_action_pressed("ui_cancel"):
|
||||
close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transfer amount controls
|
||||
# ---------------------------------------------------------------------------
|
||||
func _adjust_amount(delta: int) -> void:
|
||||
_transfer_amount = maxi(_transfer_amount + delta, 0)
|
||||
_refresh()
|
||||
|
||||
func _on_custom_submitted(text: String) -> void:
|
||||
_transfer_amount = maxi(text.to_int(), 0)
|
||||
_custom_input.text = ""
|
||||
_refresh()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deposit / Withdraw
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_deposit() -> void:
|
||||
# Move from wallet to bank — capped by what the player actually has on hand
|
||||
var amount := mini(_transfer_amount, UpgradeManager.wallet_credits)
|
||||
if amount <= 0:
|
||||
return
|
||||
UpgradeManager.wallet_credits -= amount
|
||||
UpgradeManager.hub_credits += amount
|
||||
EventBus.credits_changed.emit(UpgradeManager.wallet_credits)
|
||||
SaveManager.save()
|
||||
_refresh()
|
||||
|
||||
func _on_withdraw() -> void:
|
||||
# Move from bank to wallet — capped by bank balance
|
||||
var amount := mini(_transfer_amount, UpgradeManager.hub_credits)
|
||||
if amount <= 0:
|
||||
return
|
||||
UpgradeManager.hub_credits -= amount
|
||||
UpgradeManager.wallet_credits += amount
|
||||
EventBus.credits_changed.emit(UpgradeManager.wallet_credits)
|
||||
SaveManager.save()
|
||||
_refresh()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refresh display
|
||||
# ---------------------------------------------------------------------------
|
||||
func _refresh() -> void:
|
||||
_bank_label.text = "BANK (SAFE): %d SCRAP" % UpgradeManager.hub_credits
|
||||
_hand_label.text = "ON HAND: %d SCRAP" % UpgradeManager.wallet_credits
|
||||
_amount_label.text = "%d" % _transfer_amount
|
||||
+2
-1
@@ -121,7 +121,8 @@ func _update_display(current_hp: float) -> void:
|
||||
func _build_credits_label() -> void:
|
||||
_credits_label = Label.new()
|
||||
_credits_label.position = Vector2(16, 72)
|
||||
_credits_label.text = "SCRAP: %d" % UpgradeManager.hub_credits
|
||||
var amount := RunManager.run_credits if RunManager.run_active else UpgradeManager.wallet_credits
|
||||
_credits_label.text = "SCRAP: %d" % amount
|
||||
add_child(_credits_label)
|
||||
|
||||
func _on_credits_changed(new_total: int) -> void:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pause menu — opened with Escape during a run
|
||||
# Buttons: Resume | Return to Hub | Quit to Desktop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _root: Control = null
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 10
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_build_ui()
|
||||
visible = false
|
||||
|
||||
func _build_ui() -> void:
|
||||
_root = ColorRect.new()
|
||||
_root.color = Color(0, 0, 0, 0.65)
|
||||
_root.anchor_right = 1.0
|
||||
_root.anchor_bottom = 1.0
|
||||
_root.offset_right = 0.0
|
||||
_root.offset_bottom = 0.0
|
||||
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_root)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.anchor_left = 0.5
|
||||
vbox.anchor_top = 0.5
|
||||
vbox.anchor_right = 0.5
|
||||
vbox.anchor_bottom = 0.5
|
||||
vbox.offset_left = -120.0
|
||||
vbox.offset_top = -80.0
|
||||
vbox.offset_right = 120.0
|
||||
vbox.offset_bottom = 80.0
|
||||
vbox.add_theme_constant_override("separation", 16)
|
||||
_root.add_child(vbox)
|
||||
|
||||
_add_button(vbox, "RESUME", _on_resume)
|
||||
_add_button(vbox, "RETURN TO HUB", _on_return_to_hub)
|
||||
_add_button(vbox, "QUIT TO DESKTOP", _on_quit)
|
||||
|
||||
func _add_button(parent: Node, text: String, callback: Callable) -> void:
|
||||
var btn := Button.new()
|
||||
btn.text = text
|
||||
btn.custom_minimum_size = Vector2(240, 48)
|
||||
btn.pressed.connect(callback)
|
||||
parent.add_child(btn)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input — Escape toggles open/close
|
||||
# ---------------------------------------------------------------------------
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
if visible:
|
||||
_on_resume()
|
||||
else:
|
||||
_open()
|
||||
|
||||
func _open() -> void:
|
||||
visible = true
|
||||
get_tree().paused = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Button callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_resume() -> void:
|
||||
visible = false
|
||||
get_tree().paused = false
|
||||
|
||||
func _on_return_to_hub() -> void:
|
||||
get_tree().paused = false
|
||||
RunManager.end_run(true)
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
@@ -2,7 +2,12 @@ extends Node
|
||||
|
||||
const SLOTS: Array[String] = ["head", "torso", "left_arm", "right_arm", "legs"]
|
||||
|
||||
var hub_credits: int = 0
|
||||
var hub_credits: int = 0 # safe bank — survives death, stored in deposit machine
|
||||
var wallet_credits: int = 0 # on-hand scrap — carried by player, shown on HUD
|
||||
|
||||
func change_wallet(delta: int) -> void:
|
||||
wallet_credits = maxi(wallet_credits + delta, 0)
|
||||
EventBus.credits_changed.emit(wallet_credits)
|
||||
|
||||
# slot -> BodyPart resource path (empty string = default/base part)
|
||||
var equipped_parts: Dictionary = {
|
||||
|
||||
Reference in New Issue
Block a user