Files
DungeonsOfKharadum/src/scripts/attack_bomb.gd
2026-01-25 00:59:34 +01:00

548 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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