diff --git a/scripts/autoload/audio_manager.gd b/scripts/autoload/audio_manager.gd index 27971f2..07f6266 100644 --- a/scripts/autoload/audio_manager.gd +++ b/scripts/autoload/audio_manager.gd @@ -50,8 +50,12 @@ func play(sound_name: String) -> void: push_warning("AudioManager: Unknown sound '%s'" % sound_name) return + var stream = _sounds[sound_name] + if stream == null: + return # File doesn't exist yet, skip silently + var player := _pool[_pool_index] _pool_index = (_pool_index + 1) % POOL_SIZE - player.stream = _sounds[sound_name] + player.stream = stream player.play() diff --git a/scripts/enemies/hivemind.gd b/scripts/enemies/hivemind.gd index 3be2a6c..887b154 100644 --- a/scripts/enemies/hivemind.gd +++ b/scripts/enemies/hivemind.gd @@ -66,6 +66,29 @@ 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") +const BODY_PART_PICKUP = preload("res://scenes/upgrades/body_part_pickup.tscn") +const RUN_ITEM_PICKUP = preload("res://scenes/items/run_item_pickup.tscn") + +const ALL_PART_PATHS: Array[String] = [ + "res://data/body_parts/torso_reinforced_chassis.tres", + "res://data/body_parts/torso_lightweight_frame.tres", + "res://data/body_parts/left_arm_scatter_emitter.tres", + "res://data/body_parts/left_arm_shield_projector.tres", + "res://data/body_parts/right_arm_heavy_emitter.tres", + "res://data/body_parts/right_arm_rapid_emitter.tres", +] + +const ALL_ITEM_PATHS: Array[String] = [ + "res://data/run_items/coolant_leak.tres", + "res://data/run_items/overclock_module.tres", + "res://data/run_items/scrap_magnet.tres", + "res://data/run_items/memory_spike.tres", + "res://data/run_items/rust_coat.tres", + "res://data/run_items/static_discharge.tres", + "res://data/run_items/fragmented_map.tres", + "res://data/run_items/plating_shard.tres", + "res://data/run_items/range_booster.tres", +] # --------------------------------------------------------------------------- # State @@ -268,6 +291,22 @@ func _on_enemy_died(enemy: Node) -> void: _spawned_enemies.erase(enemy) _wave_enemies.erase(enemy) + # Play assembly sound per enemy absorbed + AudioManager.play("hivemind_assembly") + + # Defer visual effect to avoid tree manipulation errors + call_deferred("_spawn_soul_particle") + + # Check if wave is cleared — spawn next wave + if _wave_enemies.size() == 0 and _wave_index < WAVE_COUNT: + _wave_index += 1 + _wave_spawned = false # allow next wave to spawn + + +func _spawn_soul_particle() -> void: + if not is_inside_tree(): + return + # Create purple dot flying to center var part := Polygon2D.new() var pts := PackedVector2Array() @@ -276,7 +315,6 @@ func _on_enemy_died(enemy: Node) -> void: pts.append(Vector2(cos(a), sin(a)) * randf_range(8.0, 14.0)) part.polygon = pts part.color = Color(0.5, 0.2, 0.6, 0.9) - # Position at edge since we don't know exact death position part.global_position = _room_center + Vector2(randf_range(-150, 150), randf_range(-150, 150)) get_parent().add_child(part) @@ -287,11 +325,6 @@ func _on_enemy_died(enemy: Node) -> void: tw.tween_property(part, "modulate:a", 0.0, 0.2) tw.tween_callback(part.queue_free) - # Check if wave is cleared — spawn next wave - if _wave_enemies.size() == 0 and _wave_index < WAVE_COUNT: - _wave_index += 1 - _wave_spawned = false # allow next wave to spawn - func _grow_core() -> void: # Each kill grows the core toward full size @@ -508,6 +541,7 @@ func _clear_orbiters() -> void: # Sweep Attack (from Warden) # --------------------------------------------------------------------------- func _attack_sweep() -> void: + AudioManager.play("hivemind_sweep") var inner_left := -ROOM_HW + WALL_T var inner_right := ROOM_HW - WALL_T var sweep_dir := 1.0 if randf() < 0.5 else -1.0 @@ -944,5 +978,30 @@ func _die() -> void: _spawned_enemies.clear() _wave_enemies.clear() + # Always spawn item pickup + _spawn_item_drop() + # Also spawn body part if player doesn't have all + _spawn_body_part_drop() + EventBus.boss_died.emit() queue_free() + +func _spawn_item_drop() -> void: + var chosen: String = ALL_ITEM_PATHS[randi() % ALL_ITEM_PATHS.size()] + var pickup = RUN_ITEM_PICKUP.instantiate() + pickup.item = load(chosen) + pickup.global_position = global_position + Vector2(-30, 0) + get_parent().add_child(pickup) + +func _spawn_body_part_drop() -> void: + var unacquired: Array[String] = [] + for path in ALL_PART_PATHS: + if path not in UpgradeManager.acquired_part_paths: + unacquired.append(path) + if unacquired.is_empty(): + return + var chosen: String = unacquired[randi() % unacquired.size()] + var pickup = BODY_PART_PICKUP.instantiate() + pickup.part = load(chosen) + pickup.global_position = global_position + Vector2(30, 0) + get_parent().add_child(pickup) diff --git a/scripts/enemies/relay.gd b/scripts/enemies/relay.gd index 6282f96..c0ba3b5 100644 --- a/scripts/enemies/relay.gd +++ b/scripts/enemies/relay.gd @@ -50,6 +50,29 @@ const BURST_PROJ_DAMAGE := 1.0 const HOMING_PROJ_DAMAGE := 1.0 const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn") +const BODY_PART_PICKUP = preload("res://scenes/upgrades/body_part_pickup.tscn") +const RUN_ITEM_PICKUP = preload("res://scenes/items/run_item_pickup.tscn") + +const ALL_PART_PATHS: Array[String] = [ + "res://data/body_parts/torso_reinforced_chassis.tres", + "res://data/body_parts/torso_lightweight_frame.tres", + "res://data/body_parts/left_arm_scatter_emitter.tres", + "res://data/body_parts/left_arm_shield_projector.tres", + "res://data/body_parts/right_arm_heavy_emitter.tres", + "res://data/body_parts/right_arm_rapid_emitter.tres", +] + +const ALL_ITEM_PATHS: Array[String] = [ + "res://data/run_items/coolant_leak.tres", + "res://data/run_items/overclock_module.tres", + "res://data/run_items/scrap_magnet.tres", + "res://data/run_items/memory_spike.tres", + "res://data/run_items/rust_coat.tres", + "res://data/run_items/static_discharge.tres", + "res://data/run_items/fragmented_map.tres", + "res://data/run_items/plating_shard.tres", + "res://data/run_items/range_booster.tres", +] # --------------------------------------------------------------------------- # State @@ -130,6 +153,7 @@ func _tick_teleport(delta: float) -> void: _reappear() func _start_fade_out() -> void: + AudioManager.play("relay_teleport") _tp_state = TeleportState.FADING_OUT _fade_timer = FADE_OUT_TIME @@ -142,7 +166,6 @@ func _enter_transit() -> void: _transit_timer = TRANSIT_TIME func _reappear() -> void: - AudioManager.play("relay_teleport") # Pick random position within room bounds var min_x := _room_center.x - ROOM_HW + WALL_T + WALL_MARGIN var max_x := _room_center.x + ROOM_HW - WALL_T - WALL_MARGIN @@ -362,6 +385,11 @@ func _spawn_drifters() -> void: var offset := Vector2.from_angle(randf() * TAU) * randf_range(DRIFTER_OFFSET_MIN, DRIFTER_OFFSET_MAX) get_parent().add_child(drifter) drifter.global_position = global_position + offset + # Signal drifters: weaker than normal drifters + drifter.max_health = drifter.max_health * 0.5 + drifter.current_health = drifter.max_health + drifter.speed = drifter.speed * 0.7 + drifter.scale = Vector2(0.7, 0.7) # --------------------------------------------------------------------------- # Damage / Phase @@ -416,7 +444,6 @@ func _tick_climax(delta: float) -> void: func _die() -> void: AudioManager.play("enemy_death") - EventBus.boss_died.emit() # Cleanup burst projectiles for proj in _burst_projs: @@ -430,4 +457,30 @@ func _die() -> void: proj.queue_free() _homing_projs.clear() + # Always spawn item pickup + _spawn_item_drop() + # Also spawn body part if player doesn't have all + _spawn_body_part_drop() + + EventBus.boss_died.emit() queue_free() + +func _spawn_item_drop() -> void: + var chosen: String = ALL_ITEM_PATHS[randi() % ALL_ITEM_PATHS.size()] + var pickup = RUN_ITEM_PICKUP.instantiate() + pickup.item = load(chosen) + pickup.global_position = global_position + Vector2(-30, 0) + get_parent().add_child(pickup) + +func _spawn_body_part_drop() -> void: + var unacquired: Array[String] = [] + for path in ALL_PART_PATHS: + if path not in UpgradeManager.acquired_part_paths: + unacquired.append(path) + if unacquired.is_empty(): + return + var chosen: String = unacquired[randi() % unacquired.size()] + var pickup = BODY_PART_PICKUP.instantiate() + pickup.part = load(chosen) + pickup.global_position = global_position + Vector2(30, 0) + get_parent().add_child(pickup) diff --git a/scripts/enemies/warden.gd b/scripts/enemies/warden.gd index ed1c3d4..d9488b5 100644 --- a/scripts/enemies/warden.gd +++ b/scripts/enemies/warden.gd @@ -40,6 +40,30 @@ const FREEZE_DURATION := 1.5 const SWEEP_PROJ_DAMAGE := 1.0 const MINE_DAMAGE := 1.0 +const BODY_PART_PICKUP = preload("res://scenes/upgrades/body_part_pickup.tscn") +const RUN_ITEM_PICKUP = preload("res://scenes/items/run_item_pickup.tscn") + +const ALL_PART_PATHS: Array[String] = [ + "res://data/body_parts/torso_reinforced_chassis.tres", + "res://data/body_parts/torso_lightweight_frame.tres", + "res://data/body_parts/left_arm_scatter_emitter.tres", + "res://data/body_parts/left_arm_shield_projector.tres", + "res://data/body_parts/right_arm_heavy_emitter.tres", + "res://data/body_parts/right_arm_rapid_emitter.tres", +] + +const ALL_ITEM_PATHS: Array[String] = [ + "res://data/run_items/coolant_leak.tres", + "res://data/run_items/overclock_module.tres", + "res://data/run_items/scrap_magnet.tres", + "res://data/run_items/memory_spike.tres", + "res://data/run_items/rust_coat.tres", + "res://data/run_items/static_discharge.tres", + "res://data/run_items/fragmented_map.tres", + "res://data/run_items/plating_shard.tres", + "res://data/run_items/range_booster.tres", +] + # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- @@ -386,9 +410,35 @@ func _die() -> void: if is_instance_valid(entry["node"]): entry["node"].queue_free() _sweep_projs.clear() + + # Always spawn item pickup + _spawn_item_drop() + # Also spawn body part if player doesn't have all + _spawn_body_part_drop() + EventBus.boss_died.emit() queue_free() +func _spawn_item_drop() -> void: + var chosen: String = ALL_ITEM_PATHS[randi() % ALL_ITEM_PATHS.size()] + var pickup = RUN_ITEM_PICKUP.instantiate() + pickup.item = load(chosen) + pickup.global_position = global_position + Vector2(-30, 0) + get_parent().add_child(pickup) + +func _spawn_body_part_drop() -> void: + var unacquired: Array[String] = [] + for path in ALL_PART_PATHS: + if path not in UpgradeManager.acquired_part_paths: + unacquired.append(path) + if unacquired.is_empty(): + return + var chosen: String = unacquired[randi() % unacquired.size()] + var pickup = BODY_PART_PICKUP.instantiate() + pickup.part = load(chosen) + pickup.global_position = global_position + Vector2(30, 0) + get_parent().add_child(pickup) + # --------------------------------------------------------------------------- # Visual — phase-aware tints (overrides EnemyBase._tick_flash) # --------------------------------------------------------------------------- diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 698b42d..6c99e78 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -240,12 +240,39 @@ func _die() -> void: # Static Discharge — instant AoE damage to enemies in radius # --------------------------------------------------------------------------- func _trigger_static_discharge() -> void: + # Visual: yellow flash on player + _spawn_discharge_flash() + 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) + var damage := stats.discharge_damage + # Bonus damage to slowed enemies + var is_slowed := false + if enemy.get("is_slowed"): + is_slowed = true + elif enemy.get("_slow_timer") != null and enemy._slow_timer > 0: + is_slowed = true + if is_slowed: + damage *= 1.5 + enemy.take_damage(damage) + +func _spawn_discharge_flash() -> void: + var flash := Polygon2D.new() + var pts := PackedVector2Array() + for i in 16: + var a := TAU * i / 16.0 + pts.append(Vector2(cos(a), sin(a)) * stats.discharge_radius) + flash.polygon = pts + flash.color = Color(1.0, 0.9, 0.2, 0.6) # yellow flash + flash.global_position = global_position + get_parent().add_child(flash) + + var tw := flash.create_tween() + tw.tween_property(flash, "modulate:a", 0.0, 0.2) + tw.tween_callback(flash.queue_free) # --------------------------------------------------------------------------- # Coolant Leak — spawn a temporary slow zone at player position (1s) @@ -262,6 +289,16 @@ func _spawn_coolant_zone() -> void: zone.add_child(cs) zone.position = (get_parent() as Node2D).to_local(global_position) + # Visual: blue tint zone indicator + var visual := Polygon2D.new() + var pts := PackedVector2Array() + for i in 12: + var a := TAU * i / 12.0 + pts.append(Vector2(cos(a), sin(a)) * 24.0) + visual.polygon = pts + visual.color = Color(0.3, 0.6, 1.0, 0.4) # blue tint + zone.add_child(visual) + zone.body_entered.connect(func(b: Node) -> void: if b.has_method("apply_slow"): b.apply_slow(2.0, 0.4) diff --git a/scripts/ui/armory_ui.gd b/scripts/ui/armory_ui.gd index 6ffce66..07bc7e4 100644 --- a/scripts/ui/armory_ui.gd +++ b/scripts/ui/armory_ui.gd @@ -126,8 +126,11 @@ const DEFAULT_PARTS: Array[String] = [ func _rebuild_items() -> void: _items.clear() - # Add default parts first (always available) + # Add default parts first (always available), except default_head for path: String in DEFAULT_PARTS: + # Skip default head - not shown as equippable + if "head_default" in path: + continue var part := load(path) as BodyPartData if part: _items.append(part) diff --git a/scripts/ui/mini_map.gd b/scripts/ui/mini_map.gd index f850bdc..0110568 100644 --- a/scripts/ui/mini_map.gd +++ b/scripts/ui/mini_map.gd @@ -84,13 +84,17 @@ func _on_run_ended(_reached_hub: bool) -> void: func _on_room_revealed() -> void: - # Fragmented Map effect: reveal types of adjacent unvisited rooms + # Fragmented Map effect: reveal types of adjacent unvisited COMBAT rooms only if not _rooms_data.has(_current_pos): return var current_room: RoomData = _rooms_data[_current_pos] for conn_pos in current_room.connections.values(): if conn_pos not in _revealed_positions: - _revealed_positions.append(conn_pos) + if _rooms_data.has(conn_pos): + var room: RoomData = _rooms_data[conn_pos] + # Only reveal COMBAT rooms, skip ITEM, SHOP, BOSS + if room.type == RoomData.RoomType.COMBAT: + _revealed_positions.append(conn_pos) queue_redraw()