Fix critical bugs from code review

- room.gd: Add signal tracking and explicit cleanup in _exit_tree
- room.gd: Guard _on_enemy_died against race condition after room freed
- player.gd: Set _shield_ready = false on shield activation
- player.gd: Fix coolant zone lambda syntax error
- player.gd: Add apply_knockback() method for Hivemind pulse
- player.gd: Fix coolant zone memory leak (use Timer child)
- hud.gd: Remove deprecated boss bar code
This commit is contained in:
2026-03-11 09:47:37 -04:00
parent e45759980e
commit e11eddf1b4
19 changed files with 162 additions and 120 deletions
+6 -1
View File
@@ -21,11 +21,16 @@ config/icon="res://assets/icon.svg"
[autoload]
# ===== AUTOLOAD INITIALIZATION ORDER =====
# Order is critical! Dependencies must load before dependents.
# Layer 0: Event system (no dependencies)
EventBus="*res://scripts/event_bus.gd"
# Layer 1: Core managers (depend on EventBus only)
GameManager="*res://scripts/game_manager.gd"
RunManager="*res://scripts/run_manager.gd"
SaveManager="*res://scripts/save_manager.gd"
UpgradeManager="*res://scripts/upgrades/upgrade_manager.gd"
# Layer 2: Run systems (depend on Layer 1)
RunManager="*res://scripts/run_manager.gd"
CardCollection="*res://scripts/cards/card_collection.gd"
NPCManager="*res://scripts/npcs/npc_manager.gd"
LoreManager="*res://scripts/lore/lore_manager.gd"
+1 -1
View File
@@ -57,7 +57,7 @@ func _physics_process(delta: float) -> void:
State.ANCHORED:
_do_anchored(delta)
velocity *= _slow_factor
velocity *= get_slow_factor()
move_and_slide()
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -25,7 +25,7 @@ func _physics_process(delta: float) -> void:
if not is_active():
return
_do_arc_chase(delta)
velocity *= _slow_factor
velocity *= get_slow_factor()
move_and_slide()
# ---------------------------------------------------------------------------
+17 -8
View File
@@ -17,8 +17,7 @@ const SPAWN_DELAY: float = 0.5
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 _slow_effects: Array[Dictionary] = [] # [{factor, timer}] - stacking slows
var _spawn_timer: float = SPAWN_DELAY
var _player: Node2D = null
@@ -62,14 +61,24 @@ func _tick_flash(delta: float) -> void:
visual.modulate = Color(1.0, 1.0, 1.0)
func apply_slow(duration: float, factor: float) -> void:
_slow_factor = factor
_slow_timer = duration
# Stack slows by adding new effect - factors multiply together
_slow_effects.append({"factor": factor, "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
# Tick down all slow effects and remove expired ones
var i := _slow_effects.size() - 1
while i >= 0:
_slow_effects[i]["timer"] -= delta
if _slow_effects[i]["timer"] <= 0.0:
_slow_effects.remove_at(i)
i -= 1
func get_slow_factor() -> float:
# Multiply all active slow factors together
var combined := 1.0
for effect in _slow_effects:
combined *= effect["factor"]
return combined
# ---------------------------------------------------------------------------
# Damage / death
+9 -4
View File
@@ -57,6 +57,11 @@ const HOMING_LIFETIME := 7.0 # seconds before homing projectiles expire
const FREEZE_DURATION := 2.0
const ORBITER_DAMAGE := 1.0
const SWEEP_PROJ_DAMAGE := 1.0
const BEAM_DAMAGE := 1.0
const HOMING_PROJ_DAMAGE := 1.0
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")
@@ -461,7 +466,7 @@ func _create_orbiter(angle: float) -> Dictionary:
func _on_orbiter_hit(body: Node, _proj: Node2D) -> void:
if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(1.0)
body.take_damage(ORBITER_DAMAGE)
func _tick_orbiters(delta: float) -> void:
@@ -557,7 +562,7 @@ func _spawn_sweep_proj(pos: Vector2, vel: Vector2) -> void:
func _on_sweep_hit(body: Node, proj: Node2D) -> void:
if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(1.0)
body.take_damage(SWEEP_PROJ_DAMAGE)
if is_instance_valid(proj):
proj.queue_free()
@@ -738,7 +743,7 @@ func _end_beam() -> void:
func _on_beam_hit(body: Node) -> void:
if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(1.0)
body.take_damage(BEAM_DAMAGE)
func _attack_pulse() -> void:
@@ -803,7 +808,7 @@ func _spawn_homing_proj(start_angle: float) -> void:
func _on_homing_hit(body: Node, proj: Node2D) -> void:
if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(1.0)
body.take_damage(HOMING_PROJ_DAMAGE)
if is_instance_valid(proj):
proj.queue_free()
+5 -2
View File
@@ -46,6 +46,9 @@ const DRIFTER_OFFSET_MAX := 80.0
const FREEZE_DURATION := 1.5
const BURST_PROJ_DAMAGE := 1.0
const HOMING_PROJ_DAMAGE := 1.0
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
# ---------------------------------------------------------------------------
@@ -228,7 +231,7 @@ func _spawn_burst_proj(dir: Vector2) -> void:
func _on_burst_hit_player(body: Node2D, proj: Node2D) -> void:
if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(1)
body.take_damage(BURST_PROJ_DAMAGE)
if is_instance_valid(proj):
proj.queue_free()
@@ -298,7 +301,7 @@ func _spawn_homing_proj(start_angle: float) -> void:
func _on_homing_hit_player(body: Node2D, proj: Node2D) -> void:
if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(1)
body.take_damage(HOMING_PROJ_DAMAGE)
if is_instance_valid(proj):
proj.queue_free()
+1 -1
View File
@@ -65,7 +65,7 @@ func _physics_process(delta: float) -> void:
return
velocity = Vector2.ZERO
_do_fire(delta)
velocity *= _slow_factor
velocity *= get_slow_factor()
move_and_slide()
# ---------------------------------------------------------------------------
+6 -2
View File
@@ -38,6 +38,8 @@ const WIND_UP_DURATION := 0.65 # seconds of warning before beam fires
# ---------------------------------------------------------------------------
const FREEZE_DURATION := 2.0
const BEAM_DAMAGE := 1.0
# ---------------------------------------------------------------------------
# Phase 2 Drifter spawning — one at a time, over a fixed window
# ---------------------------------------------------------------------------
@@ -304,7 +306,7 @@ func _fire_beam() -> void:
area.add_child(cs)
area.body_entered.connect(func(body: Node) -> void:
if body.is_in_group("player"):
body.take_damage(1.0))
body.take_damage(BEAM_DAMAGE))
beam.add_child(area)
get_parent().call_deferred("add_child", beam)
@@ -314,11 +316,13 @@ func _fire_beam() -> void:
# Damage / death — override base to emit boss signals; no drop (room handles it)
# ---------------------------------------------------------------------------
func take_damage(amount: float) -> void:
if _phase == Phase.CLIMAX:
return # already dying
current_health -= amount
_flash_timer = 0.1
EventBus.boss_health_changed.emit(maxf(current_health, 0.0), max_health, "THE SUPERVISOR")
if current_health <= 0.0:
_die()
_enter_climax()
func _die() -> void:
EventBus.boss_died.emit()
+5 -2
View File
@@ -37,6 +37,9 @@ const MINE_AOE := 70.0
const FREEZE_DURATION := 1.5
const SWEEP_PROJ_DAMAGE := 1.0
const MINE_DAMAGE := 1.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@@ -215,7 +218,7 @@ func _spawn_sweep_proj(world_pos: Vector2, vel: Vector2) -> void:
area.add_child(cs)
area.body_entered.connect(func(body: Node) -> void:
if body.is_in_group("player"):
body.take_damage(1.0)
body.take_damage(SWEEP_PROJ_DAMAGE)
proj.queue_free())
proj.add_child(area)
@@ -321,7 +324,7 @@ func _explode_mine(entry: Dictionary) -> void:
if _player != null and is_instance_valid(_player):
var dist := node.global_position.distance_to(_player.global_position)
if dist <= MINE_AOE:
_player.take_damage(1.0)
_player.take_damage(MINE_DAMAGE)
# Brief visual flash for explosion (optional: could add particle later)
var flash := Polygon2D.new()
+13
View File
@@ -1,5 +1,18 @@
extends Node
# ---------------------------------------------------------------------------
# Initialization Order Validation
# ---------------------------------------------------------------------------
# EventBus must be the first autoload. Other autoloads depend on it.
# See project.godot for full dependency graph.
static var _initialized: bool = false
func _ready() -> void:
_initialized = true
static func assert_ready() -> void:
assert(_initialized, "EventBus not initialized. Check autoload order in project.godot.")
# ---------------------------------------------------------------------------
# Save System
# ---------------------------------------------------------------------------
+1
View File
@@ -16,6 +16,7 @@ const RunEndScreenScene = preload("res://scripts/ui/run_end_screen.gd")
var _run_end_screen: CanvasLayer = null
func _ready() -> void:
EventBus.assert_ready() # Verify autoload order
EventBus.run_started.connect(_on_run_started)
EventBus.run_ended.connect(_on_run_ended)
call_deferred("_load_game")
+1 -2
View File
@@ -15,10 +15,9 @@ func _draw() -> void:
func _on_body_entered(body: Node) -> void:
if not body.is_in_group("player") or item == null:
return
RunManager.collect_item(item)
RunManager.collect_item(item) # Triggers item_collected signal -> player_stats recalculates
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()
+1 -2
View File
@@ -73,10 +73,9 @@ func _attempt_purchase() -> void:
if player == null:
return
RunManager.collect_item(item)
RunManager.collect_item(item) # Triggers item_collected signal -> player_stats recalculates
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
+25 -4
View File
@@ -29,6 +29,9 @@ var _shield_flash_timer: float = 0.0 # brief white flash on absorb
# Oil slick — count of slick zones currently overlapping player
var _slick_count: int = 0
# Knockback — applied by enemies (e.g., Hivemind pulse)
var _knockback_velocity: Vector2 = Vector2.ZERO
func _ready() -> void:
add_to_group("player")
EventBus.room_entered.connect(_on_room_entered)
@@ -42,6 +45,8 @@ func _physics_process(delta: float) -> void:
_handle_shooting(delta)
_handle_iframes(delta)
_handle_shield(delta)
# Decay knockback
_knockback_velocity = _knockback_velocity.move_toward(Vector2.ZERO, 800.0 * delta)
move_and_slide()
# ---------------------------------------------------------------------------
@@ -62,6 +67,9 @@ func _handle_movement() -> void:
else:
velocity = input_dir * stats.speed
# Apply knockback on top of movement
velocity += _knockback_velocity
if input_dir.x != 0.0:
body.scale.x = sign(input_dir.x)
@@ -141,6 +149,7 @@ func _handle_shield(delta: float) -> void:
if _shield_ready and Input.is_physical_key_pressed(KEY_SPACE):
_shield_active = true
_shield_ready = false
# ---------------------------------------------------------------------------
# Iframes — flash body during invincibility
@@ -239,12 +248,18 @@ func _spawn_coolant_zone() -> void:
zone.body_entered.connect(func(b: Node) -> void:
if b.has_method("apply_slow"):
b.apply_slow(2.0, 0.4))
b.apply_slow(2.0, 0.4)
)
# Use a Timer child for self-cleanup (avoids SceneTreeTimer leak)
var timer := Timer.new()
timer.wait_time = 1.0
timer.one_shot = true
timer.timeout.connect(zone.queue_free)
zone.add_child(timer)
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())
timer.call_deferred("start")
# ---------------------------------------------------------------------------
# Oil slick — called by hazard Area2D nodes
@@ -255,6 +270,12 @@ func enter_slick() -> void:
func exit_slick() -> void:
_slick_count = maxi(_slick_count - 1, 0)
# ---------------------------------------------------------------------------
# Knockback — called by enemies (e.g., Hivemind pulse attack)
# ---------------------------------------------------------------------------
func apply_knockback(force: Vector2) -> void:
_knockback_velocity = force
# ---------------------------------------------------------------------------
# Room event handlers
# ---------------------------------------------------------------------------
+25
View File
@@ -10,6 +10,12 @@ const BASE_FIRE_RATE := 3.5 # shots per second
const BASE_RANGE := 400.0
const BASE_PROJ_SPEED := 520.0
# ---------------------------------------------------------------------------
# Stat caching — only recalculate when items/parts change
# ---------------------------------------------------------------------------
var _stats_dirty: bool = true
var _cached_item_count: int = -1 # Track item count to detect external changes
# ---------------------------------------------------------------------------
# Live stats (recalculated from base + equipped parts + run items)
# ---------------------------------------------------------------------------
@@ -52,6 +58,7 @@ var shield_projector: bool = false
func _ready() -> void:
EventBus.body_part_equipped.connect(_on_part_equipped)
EventBus.save_loaded.connect(_on_save_loaded)
EventBus.item_collected.connect(_on_item_collected)
recalculate()
if RunManager.player_health_carry > 0.0:
current_health = minf(RunManager.player_health_carry, max_health)
@@ -61,6 +68,7 @@ func _ready() -> void:
func _on_save_loaded() -> void:
var old_max := max_health
_stats_dirty = true
recalculate()
# Grant bonus HP from body parts on fresh start
if RunManager.player_health_carry < 0.0 and max_health > old_max:
@@ -68,7 +76,23 @@ func _on_save_loaded() -> void:
current_health = minf(current_health, max_health)
EventBus.player_health_changed.emit(current_health, max_health)
func _on_item_collected(_item: Resource) -> void:
_stats_dirty = true
recalculate()
EventBus.player_health_changed.emit(current_health, max_health)
func mark_dirty() -> void:
_stats_dirty = true
func recalculate() -> void:
# Skip if stats haven't changed (cached)
var current_item_count := RunManager.run_items.size()
if not _stats_dirty and _cached_item_count == current_item_count:
return
_cached_item_count = current_item_count
_stats_dirty = false
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")
@@ -154,6 +178,7 @@ func heal(amount: float) -> void:
func _on_part_equipped(_part: Resource, _slot: String) -> void:
var old_max := max_health
_stats_dirty = true
recalculate()
# If max_health increased, grant the extra HP immediately
if max_health > old_max:
+38 -4
View File
@@ -57,6 +57,12 @@ var _enemy_count: int = 0
var _doors: Dictionary = {} # direction -> Area2D trigger
var _physical_doors: Dictionary = {} # direction -> RoomDoor
# ---------------------------------------------------------------------------
# Signal tracking for explicit cleanup (safer than relying on auto-cleanup)
# ---------------------------------------------------------------------------
var _enemy_death_connections: Array[Dictionary] = [] # [{node, callable}]
var _door_connections: Array[Dictionary] = [] # [{node, callable}]
# ---------------------------------------------------------------------------
# Node refs (set by room_base.tscn)
# ---------------------------------------------------------------------------
@@ -219,7 +225,9 @@ func _build_door_triggers() -> void:
area.position = cfg["pos"]
# Use call_deferred so the transition runs outside the physics callback
area.body_entered.connect(_on_door_entered.bind(dir))
var callable := _on_door_entered.bind(dir)
area.body_entered.connect(callable)
_door_connections.append({"node": area, "signal": "body_entered", "callable": callable})
door_triggers.add_child(area)
_doors[dir] = area
@@ -291,7 +299,9 @@ func _spawn_enemies() -> void:
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)
var callable := _on_enemy_died
enemy.tree_exited.connect(callable, CONNECT_DEFERRED)
_enemy_death_connections.append({"node": enemy, "signal": "tree_exited", "callable": callable})
_enemy_count += 1
@@ -304,11 +314,16 @@ func _spawn_boss() -> void:
var boss = scene.instantiate()
contents.add_child(boss)
boss.global_position = to_global(Vector2.ZERO) # room centre
boss.tree_exited.connect(_on_enemy_died, CONNECT_DEFERRED)
var callable := _on_enemy_died
boss.tree_exited.connect(callable, CONNECT_DEFERRED)
_enemy_death_connections.append({"node": boss, "signal": "tree_exited", "callable": callable})
_enemy_count = 1
func _on_enemy_died() -> void:
# Guard against deferred callback running after room is freed
if not is_instance_valid(self) or not is_inside_tree():
return
_enemy_count -= 1
if _enemy_count <= 0:
data.cleared = true
@@ -353,7 +368,8 @@ func _spawn_floor_exit() -> void:
RunManager.end_run(true)
else:
RunManager.advance_floor()
get_tree().call_deferred("change_scene_to_file", "res://scenes/run/floor.tscn"))
get_tree().call_deferred("change_scene_to_file", "res://scenes/run/floor.tscn")
)
contents.add_child(area)
@@ -555,3 +571,21 @@ func _update_door_locks() -> void:
door.lock()
else:
door.unlock()
# ---------------------------------------------------------------------------
# Explicit signal cleanup on exit (safer than relying on Godot auto-cleanup)
# ---------------------------------------------------------------------------
func _exit_tree() -> void:
# Disconnect enemy death signals
for conn in _enemy_death_connections:
var node: Node = conn["node"]
if is_instance_valid(node) and node.is_connected(conn["signal"], conn["callable"]):
node.disconnect(conn["signal"], conn["callable"])
_enemy_death_connections.clear()
# Disconnect door trigger signals
for conn in _door_connections:
var node: Node = conn["node"]
if is_instance_valid(node) and node.is_connected(conn["signal"], conn["callable"]):
node.disconnect(conn["signal"], conn["callable"])
_door_connections.clear()
+3
View File
@@ -1,5 +1,8 @@
extends Node
func _ready() -> void:
EventBus.assert_ready() # Verify autoload order
var current_floor: int = 0
var current_room_id: String = ""
var run_active: bool = false
-82
View File
@@ -28,13 +28,6 @@ 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)
# ---------------------------------------------------------------------------
# DEPRECATED — Boss bar (commented out)
# ---------------------------------------------------------------------------
#const BOSS_BAR_W := 400.0
#const BOSS_BAR_H := 18.0
#const BOSS_BAR_POS := Vector2(760.0, 1040.0) # bottom center (1920×1080 viewport)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@@ -43,12 +36,6 @@ var _slots: Array = []
var _credits_label: Label
var _mini_map: Control
# DEPRECATED — Boss bar nodes (commented out)
#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
# ---------------------------------------------------------------------------
@@ -56,9 +43,6 @@ func _ready() -> void:
EventBus.player_health_changed.connect(_on_health_changed)
EventBus.credits_changed.connect(_on_credits_changed)
EventBus.save_loaded.connect(_on_save_loaded)
# DEPRECATED — Boss bar signal connections (commented out)
#EventBus.boss_health_changed.connect(_on_boss_health_changed)
#EventBus.boss_died.connect(_on_boss_died)
_build_credits_label()
_build_mini_map()
# Initialise once the scene is fully set up
@@ -153,69 +137,3 @@ func _on_health_changed(current_hp: float, max_hp: float) -> void:
_rebuild(max_hp, current_hp)
else:
_update_display(current_hp)
# ---------------------------------------------------------------------------
# DEPRECATED — Boss health bar (commented out)
# ---------------------------------------------------------------------------
#var _boss_name: String = ""
#
#func _on_boss_health_changed(current_hp: float, max_hp: float, boss_name: String) -> void:
# if _boss_bar_root == null:
# _build_boss_bar(max_hp, boss_name)
# elif _boss_name != boss_name:
# # Different boss, update label
# _boss_name = boss_name
# if _boss_bar_label != null:
# _boss_bar_label.text = boss_name
# _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
# _boss_name = ""
#
#func _build_boss_bar(max_hp: float, boss_name: String) -> void:
# _boss_max_hp = max_hp
# _boss_name = boss_name
#
# _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 = boss_name
# _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),
# ])
+2 -2
View File
@@ -83,8 +83,8 @@ func _process(_delta: float) -> void:
if _rooms_data.is_empty():
return
# Find player if not cached
if _player == null:
# Find player if not cached or freed
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
if _player == null:
return