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 = 18.0 # Base screenshake strength (stronger) 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 var rotation_speed: float = 0.0 # Angular velocity when thrown # 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 const TILE_SIZE: int = 16 const TILE_STRIDE: int = 17 # 16 + separation 1 var _explosion_tile_particle_scene: PackedScene = 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 shadow (like interactable - visible, under bomb) if shadow: shadow.visible = true shadow.modulate = Color(0, 0, 0, 0.5) shadow.z_index = -1 # Group for sync lookup when collected (multiplayer) add_to_group("attack_bomb") # Defer area/shape setup and fuse start – may run during physics (e.g. trap damage → throw) call_deferred("_deferred_ready") func _deferred_ready(): # Setup damage area (48x48 radius) – safe to touch Area2D/shape when not flushing queries 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; thrown bombs start fuse on land) 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 # Rotation when thrown (based on throw direction) if throw_force.length_squared() > 1.0: var perp = Vector2(-throw_force.y, throw_force.x) rotation_speed = sign(perp.x + perp.y) * 12.0 else: rotation_speed = 8.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 and rotation 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) sprite.rotation += rotation_speed * delta # Update shadow (like interactable - scale down when airborne for visibility) if shadow: shadow.visible = true var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 shadow.scale = Vector2.ONE * max(0.5, shadow_scale) shadow.modulate = Color(0, 0, 0, 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 (shadow visible like interactable) if sprite: sprite.position.y = 0 sprite.scale = Vector2.ONE sprite.rotation = 0.0 if shadow: shadow.visible = true shadow.scale = Vector2.ONE shadow.modulate = Color(0, 0, 0, 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.set_deferred("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 and shadow, show explosion if sprite: sprite.visible = false if shadow: shadow.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() # Spawn tile debris particles (4 pieces per affected tile, bounce, fade) _spawn_explosion_tile_particles() if has_node("SfxDebrisFromParticles"): $SfxDebrisFromParticles.play() # 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() # Avoid "RPC on yourself": call take_damage directly when victim is local peer if player_peer_id != 0 and player_peer_id == multiplayer.get_unique_id(): if body.has_method("take_damage"): body.take_damage(final_damage, attacker_pos) elif player_peer_id != 0: 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() # Avoid "RPC on yourself": call take_damage directly when enemy authority is local peer if enemy_peer_id != 0 and enemy_peer_id == multiplayer.get_unique_id(): if body.has_method("take_damage"): body.take_damage(final_damage, attacker_pos, false, false, false) elif enemy_peer_id != 0: 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 _spawn_explosion_tile_particles(): var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: return var layer = game_world.get_node_or_null("Environment/DungeonLayer0") if not layer or not layer is TileMapLayer: return if not _explosion_tile_particle_scene: _explosion_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene if not _explosion_tile_particle_scene: return var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D if not tex: return var center = global_position var r = damage_radius var layer_pos = center - layer.global_position var center_cell = layer.local_to_map(layer_pos) var half_cells = ceili(r / float(TILE_SIZE)) + 1 var parent = get_parent() if not parent: parent = game_world.get_node_or_null("Entities") if not parent: return for gx in range(center_cell.x - half_cells, center_cell.x + half_cells + 1): for gy in range(center_cell.y - half_cells, center_cell.y + half_cells + 1): var cell = Vector2i(gx, gy) if layer.get_cell_source_id(cell) < 0: continue var atlas = layer.get_cell_atlas_coords(cell) var world = layer.map_to_local(cell) + layer.global_position if world.distance_to(center) > r: continue var bx = atlas.x * TILE_STRIDE var by = atlas.y * TILE_STRIDE var h = 8.0 # TILE_SIZE / 2 var regions = [ Rect2(bx, by, h, h), Rect2(bx + h, by, h, h), Rect2(bx, by + h, h, h), Rect2(bx + h, by + h, h, h) ] # Direction from explosion center to this tile (outward) – particles fly away from bomb var to_tile = world - center var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU) # Half the particles: 2 pieces per tile instead of 4 (indices 0 and 2) for i in [0, 2]: var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D var spr = p.get_node_or_null("Sprite2D") as Sprite2D if not spr: p.queue_free() continue spr.texture = tex spr.region_enabled = true spr.region_rect = regions[i] p.global_position = world var speed = randf_range(280.0, 420.0) # Much faster - fly around more var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) p.velocity = d.normalized() * speed p.angular_velocity = randf_range(-14.0, 14.0) p.position_z = 0.0 p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down parent.add_child(p) 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 (longer duration for bigger boom) if game_world.has_method("add_screenshake"): game_world.add_screenshake(shake_strength, 0.5) # 0.5 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: # Stop fuse sound, particles, and light when collecting if has_node("SfxFuse"): $SfxFuse.stop() if fuse_particles: fuse_particles.emitting = false if fuse_light: fuse_light.enabled = false # 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) # Show "+1 Bomb" above player var floating_text_scene = load("res://scenes/floating_text.tscn") as PackedScene if floating_text_scene and by_player and is_instance_valid(by_player): var ft = floating_text_scene.instantiate() var parent = by_player.get_parent() if parent: parent.add_child(ft) ft.global_position = Vector2(by_player.global_position.x, by_player.global_position.y - 20) ft.setup("+1 Bomb", Color(0.9, 0.5, 0.2), 0.5, 0.5) # Orange-ish # Play pickup sound if has_node("SfxPickup"): $SfxPickup.play() print(by_player.name, " collected bomb!") # Sync removal to other clients so bomb doesn't keep exploding on their sessions if multiplayer.has_multiplayer_peer() and by_player and is_instance_valid(by_player): if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): by_player._rpc_to_ready_peers("_sync_bomb_collected", [name]) # 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