extends CharacterBody2D # Bomb - Explosive projectile that can be thrown or placed @export var fuse_duration: float = 3.0 # Time until explosion @export var base_damage: float = 50.0 # Base damage (increased from 30) @export var damage_radius: float = 48.0 # Area of effect radius (48x48) @export var screenshake_strength: float = 5.0 # Base screenshake strength var player_owner: Node = null var is_fused: bool = false var fuse_timer: float = 0.0 var is_thrown: bool = false # True if thrown by Dwarf, false if placed var is_exploding: bool = false var explosion_frame: int = 0 var explosion_timer: float = 0.0 # Z-axis physics (like interactable_object) var position_z: float = 0.0 var velocity_z: float = 0.0 var gravity_z: float = 500.0 var is_airborne: bool = false var throw_velocity: Vector2 = Vector2.ZERO # Blinking animation var blink_timer: float = 0.0 var bomb_visible: bool = true # Renamed to avoid shadowing CanvasItem.is_visible var blink_start_time: float = 1.0 # Start blinking 1 second before explosion # Collection var can_be_collected: bool = false var collection_delay: float = 0.2 # Can be collected after 0.2 seconds @onready var sprite = $Sprite2D @onready var explosion_sprite = $ExplosionSprite @onready var shadow = $Shadow @onready var bomb_area = $BombArea @onready var collection_area = $CollectionArea @onready var fuse_particles = $FuseParticles @onready var fuse_light = $FuseLight @onready var explosion_light = $ExplosionLight # Damage area (larger than collision) var damage_area_shape: CircleShape2D = null func _ready(): # Set collision layer to 2 (interactable objects) so it can be grabbed collision_layer = 2 collision_mask = 1 | 2 | 64 # Collide with players, objects, and walls # Connect area signals if bomb_area and not bomb_area.body_entered.is_connected(_on_bomb_area_body_entered): bomb_area.body_entered.connect(_on_bomb_area_body_entered) if collection_area and not collection_area.body_entered.is_connected(_on_collection_area_body_entered): collection_area.body_entered.connect(_on_collection_area_body_entered) # Ensure bomb sprite is visible if sprite: sprite.visible = true # Hide explosion sprite initially if explosion_sprite: explosion_sprite.visible = false # Setup damage area (48x48 radius) if bomb_area: var collision_shape = bomb_area.get_node_or_null("CollisionShape2D") if collision_shape: damage_area_shape = CircleShape2D.new() damage_area_shape.radius = damage_radius / 2.0 # 24 pixel radius for 48x48 collision_shape.shape = damage_area_shape # Start fuse if not thrown (placed bomb starts fusing immediately) if not is_thrown: _start_fuse() func setup(target_position: Vector2, owner_player: Node, throw_force: Vector2 = Vector2.ZERO, thrown: bool = false): # Don't overwrite position if already set (for thrown bombs) if not thrown: global_position = target_position player_owner = owner_player is_thrown = thrown throw_velocity = throw_force if is_thrown: # Thrown bomb - start airborne is_airborne = true position_z = 2.5 velocity_z = 100.0 # Make sure sprite is visible if sprite: sprite.visible = true # Start fuse after landing else: # Placed bomb - start fusing immediately _start_fuse() func _start_fuse(): if is_fused: return is_fused = true fuse_timer = 0.0 # Play fuse sound if has_node("SfxFuse"): $SfxFuse.play() # Start fuse particles if fuse_particles: fuse_particles.emitting = true # Enable fuse light if fuse_light: fuse_light.enabled = true print("Bomb fuse started!") func _physics_process(delta): if is_exploding: # Handle explosion animation explosion_timer += delta if explosion_sprite: # Play 9 frames of explosion animation at ~15 FPS if explosion_timer >= 0.06666667: # ~15 FPS explosion_timer = 0.0 explosion_frame += 1 if explosion_frame < 9: explosion_sprite.frame = explosion_frame else: # Explosion animation complete - remove queue_free() return # Update fuse timer if is_fused: fuse_timer += delta # Start blinking when close to explosion if fuse_timer >= (fuse_duration - blink_start_time): blink_timer += delta if blink_timer >= 0.1: # Blink every 0.1 seconds blink_timer = 0.0 bomb_visible = not bomb_visible if sprite: sprite.modulate.a = 1.0 if bomb_visible else 0.3 # Explode when fuse runs out if fuse_timer >= fuse_duration: _explode() return # Z-axis physics (if thrown) if is_thrown and is_airborne: # Apply gravity velocity_z -= gravity_z * delta position_z += velocity_z * delta # Update sprite position based on height if sprite: sprite.position.y = -position_z * 0.5 var height_scale = 1.0 - (position_z / 50.0) * 0.2 sprite.scale = Vector2.ONE * max(0.8, height_scale) # Update shadow if shadow: var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 shadow.scale = Vector2.ONE * max(0.5, shadow_scale) shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 # Apply throw velocity velocity = throw_velocity # Check if landed if position_z <= 0.0: _land() else: # On ground - reset sprite/shadow if sprite: sprite.position.y = 0 sprite.scale = Vector2.ONE if shadow: shadow.scale = Vector2.ONE shadow.modulate.a = 0.5 # Apply friction if on ground if not is_airborne: throw_velocity = throw_velocity.lerp(Vector2.ZERO, delta * 5.0) if throw_velocity.length() < 5.0: throw_velocity = Vector2.ZERO velocity = throw_velocity # Move move_and_slide() # Check for collisions while airborne (instant explode on enemy/player hit) if is_airborne and throw_velocity.length() > 50.0: for i in get_slide_collision_count(): var collision = get_slide_collision(i) var collider = collision.get_collider() if collider and (collider.is_in_group("player") or collider.is_in_group("enemy")): if collider != player_owner: # Instant explode on hit _explode() return # Enable collection after delay if is_fused and not can_be_collected: if fuse_timer >= collection_delay: can_be_collected = true if collection_area: collection_area.monitoring = true func _land(): is_airborne = false position_z = 0.0 velocity_z = 0.0 # Start fuse when landing if not is_fused: _start_fuse() func _explode(): if is_exploding: return is_exploding = true # Hide bomb sprite, show explosion if sprite: sprite.visible = false if explosion_sprite: explosion_sprite.visible = true explosion_sprite.frame = 0 explosion_frame = 0 explosion_timer = 0.0 # Stop fuse sound and particles if has_node("SfxFuse"): $SfxFuse.stop() if fuse_particles: fuse_particles.emitting = false # Disable fuse light, enable explosion light if fuse_light: fuse_light.enabled = false if explosion_light: explosion_light.enabled = true # Fade out explosion light over time var tween = create_tween() tween.tween_property(explosion_light, "energy", 0.0, 0.3) tween.tween_callback(func(): if explosion_light: explosion_light.enabled = false) # Play explosion sound if has_node("SfxExplosion"): $SfxExplosion.play() # Deal area damage _deal_explosion_damage() # Cause screenshake _cause_screenshake() # Disable collision if bomb_area: bomb_area.set_deferred("monitoring", false) if collection_area: collection_area.set_deferred("monitoring", false) print("Bomb exploded!") func _deal_explosion_damage(): if not bomb_area: return # Get all bodies in explosion radius var bodies = bomb_area.get_overlapping_bodies() # Calculate total damage including strength bonus var total_damage = base_damage # Add strength-based bonus damage if player_owner and player_owner.character_stats: var strength = player_owner.character_stats.baseStats.str + player_owner.character_stats.get_pass("str") # Add 1.5 damage per strength point var strength_bonus = strength * 1.5 total_damage += strength_bonus print("Bomb damage: base=", base_damage, " + str_bonus=", strength_bonus, " (STR=", strength, ") = ", total_damage) for body in bodies: # CRITICAL: Only the bomb owner (authority) should deal damage if player_owner and not player_owner.is_multiplayer_authority(): continue # Note: Removed the check that skips player_owner - bombs now damage the player who used them! # Calculate distance for damage falloff var distance = global_position.distance_to(body.global_position) var damage_multiplier = 1.0 - (distance / damage_radius) # Linear falloff damage_multiplier = max(0.1, damage_multiplier) # Minimum 10% damage var final_damage = total_damage * damage_multiplier # Deal damage to players if body.is_in_group("player") and body.has_method("rpc_take_damage"): var attacker_pos = player_owner.global_position if player_owner else global_position var player_peer_id = body.get_multiplayer_authority() if player_peer_id != 0: if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): body.rpc_take_damage(final_damage, attacker_pos) else: body.rpc_take_damage.rpc_id(player_peer_id, final_damage, attacker_pos) else: body.rpc_take_damage.rpc(final_damage, attacker_pos) print("Bomb hit player: ", body.name, " for ", final_damage, " damage!") # Deal damage to enemies elif body.is_in_group("enemy") and body.has_method("rpc_take_damage"): var attacker_pos = player_owner.global_position if player_owner else global_position var enemy_peer_id = body.get_multiplayer_authority() if enemy_peer_id != 0: if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): body.rpc_take_damage(final_damage, attacker_pos, false, false, false) else: body.rpc_take_damage.rpc_id(enemy_peer_id, final_damage, attacker_pos, false, false, false) else: body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, false) print("Bomb hit enemy: ", body.name, " for ", final_damage, " damage!") func _cause_screenshake(): # Calculate screenshake based on distance from local players var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: return # Get local players from player_manager var player_manager = game_world.get_node_or_null("PlayerManager") if not player_manager or not player_manager.has_method("get_local_players"): return var local_players = player_manager.get_local_players() if local_players.is_empty(): return # Find closest local player var min_distance = INF for player in local_players: if not is_instance_valid(player): continue var distance = global_position.distance_to(player.global_position) min_distance = min(min_distance, distance) # Calculate screenshake strength (inverse distance, capped) var shake_strength = screenshake_strength / max(1.0, min_distance / 50.0) shake_strength = min(shake_strength, screenshake_strength * 2.0) # Cap at 2x base # Apply screenshake if game_world.has_method("add_screenshake"): game_world.add_screenshake(shake_strength, 0.3) # 0.3 second duration func _on_bomb_area_body_entered(_body): # This is for explosion damage (handled in _deal_explosion_damage) pass func can_be_grabbed() -> bool: # Bomb can be grabbed if it's fused and can be collected return is_fused and can_be_collected and not is_exploding func on_grabbed(by_player): # When bomb is grabbed, collect it immediately (don't wait for release) if not can_be_collected or is_exploding: return if not by_player.character_stats: return var offhand_item = by_player.character_stats.equipment.get("offhand", null) var can_collect = false if not offhand_item: # Empty offhand - can collect can_collect = true elif offhand_item.item_name == "Bomb": # Already has bombs - can stack can_collect = true if can_collect: # Create bomb item var bomb_item = ItemDatabase.create_item("bomb") if bomb_item: bomb_item.quantity = 1 if not offhand_item: # Equip to offhand by_player.character_stats.equipment["offhand"] = bomb_item else: # Add to existing stack offhand_item.quantity += 1 by_player.character_stats.character_changed.emit(by_player.character_stats) # Play pickup sound if has_node("SfxPickup"): $SfxPickup.play() print(by_player.name, " collected bomb!") # Remove bomb immediately queue_free() func _on_collection_area_body_entered(_body): # This is a backup - main collection happens via can_be_grabbed/on_grabbed # But we can also handle it here if needed pass