diff --git a/src/scenes/attack_bomb.tscn b/src/scenes/attack_bomb.tscn new file mode 100644 index 0000000..2ee2b16 --- /dev/null +++ b/src/scenes/attack_bomb.tscn @@ -0,0 +1,145 @@ +[gd_scene format=3 uid="uid://bv5jfd7ck3d5u"] + +[ext_resource type="Script" uid="uid://cejgtbmf5y0wk" path="res://scripts/attack_bomb.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://ceitcsfb2fq6m" path="res://assets/gfx/fx/big-explosion.png" id="2_explosion"] +[ext_resource type="Texture2D" uid="uid://dkpritx47nd4m" path="res://assets/gfx/pickups/items_n_shit.png" id="2_ng1nl"] +[ext_resource type="AudioStream" uid="uid://d4dweg04wrw6a" path="res://assets/audio/sfx/sub_weapons/bomb_fuse.mp3" id="3_fuse"] +[ext_resource type="AudioStream" uid="uid://qcb5u7dqw1ck" path="res://assets/audio/sfx/explode_01.wav.mp3" id="4_explode"] +[ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="5_pickup"] + +[sub_resource type="Gradient" id="Gradient_shadow"] +colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_shadow"] +gradient = SubResource("Gradient_shadow") +fill = 1 +fill_from = Vector2(0.5, 0.5) +fill_to = Vector2(0.8, 0.8) + +[sub_resource type="CircleShape2D" id="CircleShape2D_bomb"] +radius = 8.0 + +[sub_resource type="CircleShape2D" id="CircleShape2D_collection"] +radius = 16.0 + +[sub_resource type="Curve" id="Curve_ng1nl"] +_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.4198473, 0.8850688), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="CurveTexture" id="CurveTexture_bugki"] +curve = SubResource("Curve_ng1nl") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_fuse"] +particle_flag_disable_z = true +direction = Vector3(0, -1, 0) +spread = 360.0 +initial_velocity_min = 20.0 +initial_velocity_max = 50.0 +gravity = Vector3(0, -30, 0) +scale_min = 0.3 +scale_max = 0.8 +color = Color(0.9137255, 0.44651705, 0.2455241, 1) +alpha_curve = SubResource("CurveTexture_bugki") +hue_variation_min = -0.100000024 +hue_variation_max = 0.12999998 + +[sub_resource type="Gradient" id="Gradient_fuse_light"] +offsets = PackedFloat32Array(0, 0.7250608, 1) +colors = PackedColorArray(1, 0.4, 0.1, 1, 1, 0.4, 0.1, 0.5, 1, 0.4, 0.1, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_fuse_light"] +gradient = SubResource("Gradient_fuse_light") +fill = 1 +fill_from = Vector2(0.5, 0.5) +fill_to = Vector2(0.85897434, 0.8247863) + +[sub_resource type="Gradient" id="Gradient_explosion_light"] +offsets = PackedFloat32Array(0, 0.69343066, 1) +colors = PackedColorArray(1, 0.6, 0.2, 1, 1, 0.6, 0.2, 0.5, 1, 0.6, 0.2, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_explosion_light"] +gradient = SubResource("Gradient_explosion_light") +fill = 1 +fill_from = Vector2(0.5, 0.5) +fill_to = Vector2(0.9102564, 0.9188034) + +[node name="Bomb" type="CharacterBody2D" unique_id=367943636] +collision_layer = 2 +motion_mode = 1 +script = ExtResource("1_script") + +[node name="Shadow" type="Sprite2D" parent="." unique_id=1404868451] +z_index = 1 +z_as_relative = false +scale = Vector2(0.1, 0.1) +texture = SubResource("GradientTexture2D_shadow") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=818862430] +texture = ExtResource("2_ng1nl") +hframes = 20 +vframes = 14 +frame = 199 + +[node name="ExplosionSprite" type="Sprite2D" parent="." unique_id=2038174438] +position = Vector2(0, -23) +texture = ExtResource("2_explosion") +hframes = 9 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1640139333] +shape = SubResource("CircleShape2D_bomb") +debug_color = Color(0.3825145, 0.70196074, 0.30829018, 0.41960785) + +[node name="BombArea" type="Area2D" parent="." unique_id=97949411] +collision_layer = 4 +collision_mask = 3 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="BombArea" unique_id=963327610] +shape = SubResource("CircleShape2D_bomb") +debug_color = Color(0, 0.06808392, 0.70196074, 0.41960785) + +[node name="CollectionArea" type="Area2D" parent="." unique_id=1088408959] +collision_layer = 0 +monitoring = false + +[node name="CollisionShape2D" type="CollisionShape2D" parent="CollectionArea" unique_id=1383974781] +shape = SubResource("CircleShape2D_collection") +debug_color = Color(0.38218734, 0.5838239, 0.70196074, 0.41960785) + +[node name="SfxFuse" type="AudioStreamPlayer2D" parent="." unique_id=1095147141] +stream = ExtResource("3_fuse") +volume_db = -2.0 +attenuation = 2.0 +bus = &"Sfx" + +[node name="SfxExplosion" type="AudioStreamPlayer2D" parent="." unique_id=1652373167] +stream = ExtResource("4_explode") +volume_db = 2.0 +attenuation = 3.0 +bus = &"Sfx" + +[node name="SfxPickup" type="AudioStreamPlayer2D" parent="." unique_id=898603969] +stream = ExtResource("5_pickup") +volume_db = -2.0 +attenuation = 2.0 +bus = &"Sfx" + +[node name="FuseParticles" type="GPUParticles2D" parent="." unique_id=1234567890] +z_index = 2 +position = Vector2(6, -5) +amount = 32 +lifetime = 0.3 +randomness = 1.0 +process_material = SubResource("ParticleProcessMaterial_fuse") + +[node name="FuseLight" type="PointLight2D" parent="." unique_id=1286608618] +position = Vector2(6, -5) +enabled = false +color = Color(1, 0.4, 0.1, 1) +energy = 0.8 +texture = SubResource("GradientTexture2D_fuse_light") + +[node name="ExplosionLight" type="PointLight2D" parent="." unique_id=1111111111] +enabled = false +color = Color(1, 0.6, 0.2, 1) +energy = 2.5 +texture = SubResource("GradientTexture2D_explosion_light") diff --git a/src/scenes/attack_spell_flame.tscn b/src/scenes/attack_spell_flame.tscn index 32a6a7b..0b65177 100644 --- a/src/scenes/attack_spell_flame.tscn +++ b/src/scenes/attack_spell_flame.tscn @@ -81,6 +81,8 @@ panning_strength = 1.09 [node name="SfxInit" type="AudioStreamPlayer2D" parent="." unique_id=467371620] stream = ExtResource("10_2hde6") +volume_db = -11.018 +pitch_scale = 0.93 attenuation = 1.5157177 panning_strength = 1.04 bus = &"Sfx" diff --git a/src/scripts/attack_bomb.gd b/src/scripts/attack_bomb.gd new file mode 100644 index 0000000..304c17e --- /dev/null +++ b/src/scripts/attack_bomb.gd @@ -0,0 +1,419 @@ +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 diff --git a/src/scripts/attack_bomb.gd.uid b/src/scripts/attack_bomb.gd.uid new file mode 100644 index 0000000..86e58d7 --- /dev/null +++ b/src/scripts/attack_bomb.gd.uid @@ -0,0 +1 @@ +uid://cejgtbmf5y0wk diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 1461cec..35dc274 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -6,6 +6,12 @@ extends Node2D @onready var camera = $Camera2D @onready var network_manager = $"/root/NetworkManager" +# Screenshake system +var screenshake_offset: Vector2 = Vector2.ZERO +var screenshake_timer: float = 0.0 +var screenshake_duration: float = 0.0 +var screenshake_strength: float = 0.0 + var local_players = [] const BASE_CAMERA_ZOOM: float = 4.0 const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices @@ -1713,14 +1719,25 @@ func _update_camera(): if local_players.is_empty(): return + # Update screenshake + if screenshake_timer > 0.0: + screenshake_timer -= get_process_delta_time() + var shake_amount = screenshake_strength * (screenshake_timer / screenshake_duration) + screenshake_offset = Vector2( + randf_range(-shake_amount, shake_amount), + randf_range(-shake_amount, shake_amount) + ) + else: + screenshake_offset = Vector2.ZERO + # Calculate center of all local players var center = Vector2.ZERO for player in local_players: center += player.position center /= local_players.size() - # Smooth camera movement - camera.position = camera.position.lerp(center, 0.1) + # Smooth camera movement (with screenshake) + camera.position = camera.position.lerp(center + screenshake_offset, 0.1) # Base zoom with aspect ratio adjustment (show more on wider screens) var viewport_size = get_viewport().get_visible_rect().size @@ -1760,6 +1777,12 @@ func _update_camera(): var lerped_center = center + cursor_offset * CURSOR_CAMERA_LERP_AMOUNT camera.position = camera.position.lerp(lerped_center, 0.1) +func add_screenshake(strength: float, duration: float): + # Add screenshake effect + screenshake_strength = max(screenshake_strength, strength) # Use max if already shaking + screenshake_duration = max(screenshake_duration, duration) # Use max duration + screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger + func _init_mouse_cursor(): # Create cursor layer with high Z index cursor_layer = CanvasLayer.new() diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index ab7e89b..4108d32 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -470,6 +470,11 @@ func on_released(by_player): print(name, " released by ", by_player.name) func on_thrown(by_player, force: Vector2): + # Special handling for bombs - convert to projectile when thrown + if object_type == "Bomb": + _convert_to_bomb_projectile(by_player, force) + return + # Only allow throwing if object is liftable if not is_liftable: return @@ -490,6 +495,46 @@ func on_thrown(by_player, force: Vector2): print(name, " thrown with velocity ", throw_velocity) +func _convert_to_bomb_projectile(by_player, force: Vector2): + # Convert bomb object to projectile bomb when thrown + var attack_bomb_scene = load("res://scenes/attack_bomb.tscn") + if not attack_bomb_scene: + push_error("ERROR: Could not load attack_bomb scene!") + return + + # Only authority can spawn bombs + if not is_multiplayer_authority(): + return + + # Store current position before freeing + var current_pos = global_position + + # Spawn bomb projectile at current position + var bomb = attack_bomb_scene.instantiate() + get_parent().add_child(bomb) + bomb.global_position = current_pos # Use current position, not target + + # Set multiplayer authority + if multiplayer.has_multiplayer_peer(): + bomb.set_multiplayer_authority(by_player.get_multiplayer_authority()) + + # Setup bomb with throw physics (pass force as throw_velocity) + # The bomb will use throw_velocity for movement + bomb.setup(current_pos, by_player, force, true) # true = is_thrown, force is throw_velocity + + # Make sure bomb sprite is visible + if bomb.has_node("Sprite2D"): + bomb.get_node("Sprite2D").visible = true + + # Sync bomb throw to other clients + if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority() and by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): + by_player._rpc_to_ready_peers("_sync_throw_bomb", [current_pos, force]) + + # Remove the interactable object + queue_free() + + print("Bomb object converted to projectile and thrown!") + @rpc("authority", "unreliable") func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool): # Only update on clients (server already has correct state) @@ -663,8 +708,23 @@ func setup_pushable_high_box(): if sprite: sprite.frame = bottom_frames[index] - if sprite_above: - sprite_above.frame = top_frames[index] + if sprite_above: + sprite_above.frame = top_frames[index] + +func setup_bomb(): + object_type = "Bomb" + is_grabbable = true + can_be_pushed = false + is_destroyable = false # Bombs don't break, they explode + is_liftable = true + weight = 0.5 # Light weight for easy throwing + + # Set bomb sprite (frame 199 from items_n_shit.png) + if sprite: + sprite.texture = load("res://assets/gfx/pickups/items_n_shit.png") + sprite.hframes = 20 + sprite.vframes = 14 + sprite.frame = 199 func _open_chest(by_player: Node = null): if is_chest_opened: diff --git a/src/scripts/item.gd b/src/scripts/item.gd index b73ac00..6df034c 100644 --- a/src/scripts/item.gd +++ b/src/scripts/item.gd @@ -32,7 +32,8 @@ enum WeaponType { STAFF, SPEAR, MACE, - SPELLBOOK + SPELLBOOK, + BOMB } var use_function = null diff --git a/src/scripts/item_database.gd b/src/scripts/item_database.gd index cea90be..8543299 100644 --- a/src/scripts/item_database.gd +++ b/src/scripts/item_database.gd @@ -1254,6 +1254,24 @@ static func _load_all_items(): {"old_color": Color(1.0, 1.0, 1.0), "new_color": Color(1.0, 0.8, 0.6)} # Warm orange tint for fire ] }) + + # BOMB item (sprite index 199 = row 9, col 19) + _register_item("bomb", { + "item_name": "Bomb", + "description": "A dangerous explosive device", + "item_type": Item.ItemType.Equippable, + "equipment_type": Item.EquipmentType.OFFHAND, + "weapon_type": Item.WeaponType.BOMB, + "spriteFrame": 199, # 9 * 20 + 19 + "quantity": 1, + "can_have_multiple_of": true, + "modifiers": {}, + "buy_cost": 50, + "sell_worth": 15, + "weight": 0.5, + "rarity": ItemRarity.COMMON, + "drop_chance": 5.0 + }) # Register an item in the database static func _register_item(item_id: String, item_data: Dictionary): diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 6f4c2a6..e819ff6 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -38,6 +38,7 @@ var is_pulling = false # True when pulling (moving backwards while pushing) var is_disarming = false # True when disarming a trap var grab_button_pressed_time = 0.0 var just_grabbed_this_frame = false # Prevents immediate release bug - persists across frames +var grab_released_while_lifting = false # Track if grab was released while lifting (for drop-on-second-press logic) var grab_start_time = 0.0 # Time when we first grabbed (to detect quick tap) var grab_tap_threshold = 0.2 # Seconds to distinguish tap from hold var last_movement_direction = Vector2.DOWN # Track last direction for placing objects @@ -78,6 +79,9 @@ var spell_charge_tint: Color = Color(2.0, 0.2, 0.2, 2.0) # Tint color when fully var spell_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation var spell_charge_tint_pulse_speed: float = 8.0 # Speed of tint pulse animation while charging var spell_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged +var bow_charge_tint: Color = Color(1.2, 1.2, 1.2, 1.0) # White tint when fully charged +var bow_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation +var bow_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged var original_sprite_tints: Dictionary = {} # Store original tint values for restoration var spell_incantation_played: bool = false # Track if incantation sound has been played var burn_debuff_timer: float = 0.0 # Timer for burn debuff @@ -93,6 +97,7 @@ var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile var attack_arrow_scene = preload("res://scenes/attack_arrow.tscn") var flame_spell_scene = preload("res://scenes/attack_spell_flame.tscn") # Flame spell for Tome of Flames +var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile var blood_scene = preload("res://scenes/blood_clot.tscn") # Simulated Z-axis for height (when thrown) @@ -674,6 +679,14 @@ func _setup_player_appearance(): character_stats.equipment["offhand"] = starting_arrows print("Elf player ", name, " spawned with short bow and 3 arrows") + # Give Dwarf race starting bomb + if selected_race == "Dwarf": + var starting_bomb = ItemDatabase.create_item("bomb") + if starting_bomb: + starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start + character_stats.equipment["offhand"] = starting_bomb + print("Dwarf player ", name, " spawned with 5 bombs") + # Give Human race starting spellbook (Tome of Flames) if selected_race == "Human": var starting_tome = ItemDatabase.create_item("tome_of_flames") @@ -1757,6 +1770,25 @@ func _physics_process(delta): # Reset pulse timer when not charging spell_charge_tint_pulse_time = 0.0 + # Update bow charge tint (when fully charged) + if is_charging_bow: + var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time + # Smooth curve: charge from 0.2s to 1.0s + var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s + + # Update tint pulse timer when fully charged + if charge_progress >= 1.0: + # Use fast pulse speed when fully charged + bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged + _apply_bow_charge_tint() + else: + bow_charge_tint_pulse_time = 0.0 + _clear_bow_charge_tint() + else: + # Reset pulse timer when not charging + bow_charge_tint_pulse_time = 0.0 + _clear_bow_charge_tint() + # Update burn debuff (works on both authority and clients) if burn_debuff_timer > 0.0: burn_debuff_timer -= delta @@ -2213,6 +2245,9 @@ func _handle_interactions(): if grab_just_pressed and is_charging_bow: is_charging_bow = false + # Clear bow charge tint + _clear_bow_charge_tint() + # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() @@ -2281,6 +2316,9 @@ func _handle_interactions(): _stop_spell_charge_particles() _clear_spell_charge_tint() + # Return to IDLE animation + _set_animation("IDLE") + # Stop spell charging sounds if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() @@ -2308,21 +2346,39 @@ func _handle_interactions(): _cast_flame_spell(target_pos) # Play FINISH_SPELL animation after casting _set_animation("FINISH_SPELL") + + # Stop charging and clear tint (but let incantation sound finish) + is_charging_spell = false + spell_incantation_played = false + _stop_spell_charge_particles() + _clear_spell_charge_tint() # This will restore original tints + + # Stop spell charging sound, but let incantation play to completion + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + # Don't stop SfxSpellIncantation - let it finish playing + + # Sync spell charge end to other clients + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() else: # Not fully charged or no target - just cancel without casting print(name, " spell not cast (charge: ", charge_time, "s, fully charged: ", is_fully_charged, ", target: ", target_pos, ")") - - # Stop charging and clear tint - is_charging_spell = false - spell_incantation_played = false - _stop_spell_charge_particles() - _clear_spell_charge_tint() # This will restore original tints - - # Stop spell charging sounds - if has_node("SfxSpellCharge"): - $SfxSpellCharge.stop() - if has_node("SfxSpellIncantation"): - $SfxSpellIncantation.stop() + + # Stop charging and clear tint + is_charging_spell = false + spell_incantation_played = false + _stop_spell_charge_particles() + _clear_spell_charge_tint() # This will restore original tints + + # Return to IDLE animation + _set_animation("IDLE") + + # Stop spell charging sounds + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() # Sync spell charge end to other clients if multiplayer.has_multiplayer_peer(): @@ -2339,6 +2395,9 @@ func _handle_interactions(): _stop_spell_charge_particles() _clear_spell_charge_tint() + # Return to IDLE animation + _set_animation("IDLE") + # Stop spell charging sounds if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() @@ -2382,6 +2441,79 @@ func _handle_interactions(): # No nearby trap - reset disarming flag is_disarming = false + # Check for bomb usage (if bomb equipped in offhand) + # Also check if we're already holding a bomb - if so, skip normal grab handling + var is_holding_bomb = false + if held_object and "object_type" in held_object: + # Check if it's a bomb by checking object_type + if held_object.object_type == "Bomb": + is_holding_bomb = true + + if character_stats and character_stats.equipment.has("offhand"): + var offhand_item = character_stats.equipment["offhand"] + if offhand_item and offhand_item.weapon_type == Item.WeaponType.BOMB and offhand_item.quantity > 0: + # Check if there's a grabbable object nearby - prioritize grabbing over bomb + var nearby_grabbable = null + if grab_area: + var bodies = grab_area.get_overlapping_bodies() + for body in bodies: + if body == self: + continue + var is_grabbable = false + if body.has_method("can_be_grabbed"): + if body.can_be_grabbed(): + is_grabbable = true + elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): + is_grabbable = true + + if is_grabbable: + var distance = position.distance_to(body.position) + if distance < grab_range: + nearby_grabbable = body + break + + # Only use bomb if no grabbable object nearby and not lifting/grabbing + if grab_just_pressed and not nearby_grabbable and not is_lifting and not held_object: + # Use bomb based on race + if character_stats.race == "Dwarf": + # Dwarf: Create interactable bomb object that can be lifted/thrown + _create_bomb_object() + # Skip the normal grab handling below - bomb is already lifted + just_grabbed_this_frame = true # Set to true to prevent immediate release + grab_start_time = Time.get_ticks_msec() / 1000.0 # Record grab time + return + else: + # Human/Elf: Place bomb directly + var game_world = get_tree().get_first_node_in_group("game_world") + var target_pos = Vector2.ZERO + if game_world and game_world.has_method("get_grid_locked_cursor_position"): + target_pos = game_world.get_grid_locked_cursor_position() + + if target_pos != Vector2.ZERO: + # Consume one bomb + offhand_item.quantity -= 1 + var remaining = offhand_item.quantity + if offhand_item.quantity <= 0: + character_stats.equipment["offhand"] = null + if character_stats: + character_stats.character_changed.emit(character_stats) + + # Place bomb + _place_bomb(target_pos) + + print(name, " used bomb! Remaining: ", remaining) + just_grabbed_this_frame = false + return + + # If holding a bomb, skip normal grab press handling to prevent dropping it + # But still allow grab release handling for the drop-on-second-press logic + if is_holding_bomb: + # Update bomb position if holding it + if held_object and grab_button_down and is_lifting: + _update_lifted_object() + # Skip grab press handling, but continue to release handling below + # This allows the drop-on-second-press logic to work + # Track how long grab button is held if grab_button_down: grab_button_pressed_time += get_process_delta_time() @@ -2394,18 +2526,28 @@ func _handle_interactions(): # Handle grab button press FIRST (before checking release) # Note: just_grabbed_this_frame is reset at the END of this function if grab_just_pressed and can_grab: - print("DEBUG: grab_just_pressed, can_grab=", can_grab, " held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down) - if not held_object: - # Try to grab something (but don't lift yet - wait for release to determine if it's a tap) - print("DEBUG: Calling _try_grab()") - _try_grab() - just_grabbed_this_frame = true - # Record when we grabbed to detect quick tap on release - grab_start_time = Time.get_ticks_msec() / 1000.0 - print("DEBUG: After _try_grab(), held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down, " just_grabbed_this_frame=", just_grabbed_this_frame) - elif is_lifting: - # Already lifting - always place down (throwing is now only via attack button) - _place_down_object() + # Skip grab press handling if holding a bomb (to prevent instant drop) + # But still allow the drop-on-second-press logic + if is_holding_bomb: + if is_lifting and grab_released_while_lifting: + # Already lifting AND grab was released then pressed again - drop the bomb + _place_down_object() + grab_released_while_lifting = false + else: + # Normal grab handling for non-bomb objects + print("DEBUG: grab_just_pressed, can_grab=", can_grab, " held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down) + if not held_object: + # Try to grab something (but don't lift yet - wait for release to determine if it's a tap) + print("DEBUG: Calling _try_grab()") + _try_grab() + just_grabbed_this_frame = true + # Record when we grabbed to detect quick tap on release + grab_start_time = Time.get_ticks_msec() / 1000.0 + print("DEBUG: After _try_grab(), held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down, " just_grabbed_this_frame=", just_grabbed_this_frame) + elif is_lifting and grab_released_while_lifting: + # Already lifting AND grab was released then pressed again - drop the object + _place_down_object() + grab_released_while_lifting = false # Handle grab button release # CRITICAL: Don't process release if: @@ -2413,12 +2555,16 @@ func _handle_interactions(): # 2. Button is still down (shouldn't happen, but safety check) # 3. grab_just_pressed is also true (same frame tap) if grab_just_released and held_object: + # For bombs that are already lifted, skip the "just grabbed" logic + # and go straight to the normal release handling (drop-on-second-press) + var is_bomb_already_lifted = is_holding_bomb and is_lifting + # Check if we just grabbed (either this frame or recently) # Use grab_start_time to determine if this was a quick tap var time_since_grab = (Time.get_ticks_msec() / 1000.0) - grab_start_time var was_recent_grab = time_since_grab <= grab_tap_threshold * 2.0 # Give some buffer - if just_grabbed_this_frame or (grab_start_time > 0.0 and was_recent_grab): + if not is_bomb_already_lifted and (just_grabbed_this_frame or (grab_start_time > 0.0 and was_recent_grab)): # Just grabbed - check if it was a quick tap (within threshold) var was_quick_tap = time_since_grab <= grab_tap_threshold print("DEBUG: Release after grab - was_quick_tap=", was_quick_tap, " time_since_grab=", time_since_grab, " threshold=", grab_tap_threshold, " is_pushing=", is_pushing) @@ -2455,11 +2601,11 @@ func _handle_interactions(): print("DEBUG: Release check - grab_just_released=", grab_just_released, " held_object=", held_object != null, " just_grabbed_this_frame=", just_grabbed_this_frame, " grab_button_down=", grab_button_down, " grab_just_pressed=", grab_just_pressed, " can_release=", can_release) if can_release: print("DEBUG: Processing release - is_lifting=", is_lifting, " is_pushing=", is_pushing) - # Button was just released - release the object + # Button was just released if is_lifting: - # If lifting, place down - print("DEBUG: Releasing lifted object") - _place_down_object() + # If lifting, mark that grab was released (but don't drop - wait for next press) + print("DEBUG: Grab released while lifting - will drop on next press") + grab_released_while_lifting = true elif is_pushing: # If pushing, stop pushing _stop_pushing() @@ -2470,6 +2616,9 @@ func _handle_interactions(): if held_object and grab_button_down: if is_lifting: _update_lifted_object() + # Clear the "released while lifting" flag if button is held again + if grab_released_while_lifting: + grab_released_while_lifting = false elif is_pushing: _update_pushed_object() else: @@ -2551,6 +2700,9 @@ func _handle_interactions(): # Release bow and shoot is_charging_bow = false + # Clear bow charge tint + _clear_bow_charge_tint() + # Lock movement for 0.15 seconds when bow is released movement_lock_timer = 0.15 @@ -2560,25 +2712,32 @@ func _handle_interactions(): _perform_attack() print(name, " released bow and shot arrow at ", bow_charge_percentage * 100, "% charge (", charge_time, "s)") - else: - # Reset charging if conditions changed (no bow/arrows, started lifting/pushing) + + # Handle normal attack (non-bow or no arrows) or cancel bow if conditions changed + if not (has_bow_and_arrows and not is_lifting and not is_pushing): + # Conditions for charging are no longer met (no bow/arrows, started lifting/pushing) + # Only cancel if we were actually charging if is_charging_bow: $SfxBuckleBow.stop() is_charging_bow = false + # Clear bow charge tint + _clear_bow_charge_tint() + # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() print(name, " bow charge cancelled (conditions changed)") - - # Normal attack (non-bow or no arrows) - if attack_just_pressed and can_attack: - if is_lifting: - # Attack while lifting -> throw immediately in facing direction - _force_throw_held_object(facing_direction_vector) - elif not is_pushing: - _perform_attack() + + # Normal attack (non-bow or no arrows) + # Also allow throwing when lifting (even if bow is equipped) + if attack_just_pressed and can_attack: + if is_lifting: + # Attack while lifting -> throw immediately in facing direction + _force_throw_held_object(facing_direction_vector) + elif not has_bow_and_arrows and not is_pushing: + _perform_attack() # Note: just_grabbed_this_frame is reset when we block a release, or stays true if we grabbed this frame # This ensures it persists to the next frame to block immediate release @@ -2652,6 +2811,12 @@ func _try_grab(): closest_body.set_collision_mask_value(2, false) # Disable collision with other objects # IMPORTANT: Keep collision with walls (layer 7) enabled so we can detect wall collisions! closest_body.set_collision_mask_value(7, true) # Enable collision with walls + elif closest_body.has_method("can_be_grabbed") and closest_body.can_be_grabbed(): + # Bomb or other grabbable CharacterBody2D - disable collision + closest_body.set_collision_layer_value(2, false) + closest_body.set_collision_mask_value(1, false) + closest_body.set_collision_mask_value(2, false) + closest_body.set_collision_mask_value(7, true) # Keep wall collision elif _is_player(closest_body): # Players are on layer 1 closest_body.set_collision_layer_value(1, false) @@ -2827,6 +2992,7 @@ func reset_grab_state(): initial_player_position = Vector2.ZERO just_grabbed_this_frame = false grab_start_time = 0.0 + grab_released_while_lifting = false was_dragging_last_frame = false # Reset to idle animation @@ -2930,34 +3096,49 @@ func _throw_object(): is_lifting = false is_pushing = false + # Track if on_thrown was already called (bombs convert to projectile and free themselves) + var on_thrown_called = false + + # Check if it's a bomb (bombs convert to projectile and free themselves) + var is_bomb = false + if thrown_obj and is_instance_valid(thrown_obj) and "object_type" in thrown_obj: + is_bomb = (thrown_obj.object_type == "Bomb") + # Re-enable collision completely - if _is_box(thrown_obj): - # Box: set position and physics first + if _is_box(thrown_obj) or is_bomb: + # Box or Bomb: set position and physics first thrown_obj.global_position = throw_start_pos - # Set throw velocity for box (same force as player throw) + # Set throw velocity (same force as player throw) if "throw_velocity" in thrown_obj: - thrown_obj.throw_velocity = throw_direction * throw_force / thrown_obj.weight + var obj_weight = thrown_obj.weight if "weight" in thrown_obj else 1.0 + thrown_obj.throw_velocity = throw_direction * throw_force / obj_weight if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false - # Make box airborne with same arc as players + # Make box/bomb airborne with same arc as players if "is_airborne" in thrown_obj: thrown_obj.is_airborne = true thrown_obj.position_z = 2.5 thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale - # Call on_thrown if available - if thrown_obj.has_method("on_thrown"): + # Call on_thrown if available (check validity first) + # Note: For bombs, this will convert to projectile and free the object + if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) + on_thrown_called = true + # Check if object was freed (bomb conversion) + if not is_instance_valid(thrown_obj): + thrown_obj = null - # ⚡ Delay collision re-enable to prevent self-collision - await get_tree().create_timer(0.1).timeout + # ⚡ Delay collision re-enable to prevent self-collision (only if object still exists) if thrown_obj and is_instance_valid(thrown_obj): - thrown_obj.set_collision_layer_value(2, true) - thrown_obj.set_collision_mask_value(1, true) - thrown_obj.set_collision_mask_value(2, true) - thrown_obj.set_collision_mask_value(7, true) + await get_tree().create_timer(0.1).timeout + if thrown_obj and is_instance_valid(thrown_obj): + thrown_obj.set_collision_layer_value(2, true) + thrown_obj.set_collision_mask_value(1, true) + thrown_obj.set_collision_mask_value(2, true) + thrown_obj.set_collision_mask_value(7, true) elif _is_player(thrown_obj): # Player: set position and physics first thrown_obj.global_position = throw_start_pos @@ -2980,8 +3161,41 @@ func _throw_object(): thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_mask_value(1, true) thrown_obj.set_collision_mask_value(7, true) + elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): + # Bomb or other grabbable object - handle like box + thrown_obj.global_position = throw_start_pos + + # Set throw velocity + if "throw_velocity" in thrown_obj: + thrown_obj.throw_velocity = throw_direction * throw_force / (thrown_obj.weight if "weight" in thrown_obj else 1.0) + if "is_frozen" in thrown_obj: + thrown_obj.is_frozen = false + + # Make airborne + if "is_airborne" in thrown_obj: + thrown_obj.is_airborne = true + thrown_obj.position_z = 2.5 + thrown_obj.velocity_z = 100.0 + + # Call on_thrown if available (bombs will convert to projectile here) + if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): + thrown_obj.on_thrown(self, throw_direction * throw_force) + on_thrown_called = true + # Check if object was freed (bomb conversion) + if not is_instance_valid(thrown_obj): + thrown_obj = null + + # Delay collision re-enable (only if object still exists) + if thrown_obj and is_instance_valid(thrown_obj): + await get_tree().create_timer(0.1).timeout + if thrown_obj and is_instance_valid(thrown_obj): + thrown_obj.set_collision_layer_value(2, true) + thrown_obj.set_collision_mask_value(1, true) + thrown_obj.set_collision_mask_value(2, true) + thrown_obj.set_collision_mask_value(7, true) - if thrown_obj.has_method("on_thrown"): + # Only call on_thrown if it wasn't already called (prevents double-call for bombs) + if not on_thrown_called and thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) # Play throw animation @@ -3026,34 +3240,49 @@ func _force_throw_held_object(direction: Vector2): is_lifting = false is_pushing = false + # Track if on_thrown was already called (bombs convert to projectile and free themselves) + var on_thrown_called = false + + # Check if it's a bomb (bombs convert to projectile and free themselves) + var is_bomb = false + if thrown_obj and is_instance_valid(thrown_obj) and "object_type" in thrown_obj: + is_bomb = (thrown_obj.object_type == "Bomb") + # Re-enable collision completely - if _is_box(thrown_obj): - # Box: set position and physics first + if _is_box(thrown_obj) or is_bomb: + # Box or Bomb: set position and physics first thrown_obj.global_position = throw_start_pos - # Set throw velocity for box (same force as player throw) + # Set throw velocity (same force as player throw) if "throw_velocity" in thrown_obj: - thrown_obj.throw_velocity = throw_direction * throw_force / thrown_obj.weight + var obj_weight = thrown_obj.weight if "weight" in thrown_obj else 1.0 + thrown_obj.throw_velocity = throw_direction * throw_force / obj_weight if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false - # Make box airborne with same arc as players + # Make box/bomb airborne with same arc as players if "is_airborne" in thrown_obj: thrown_obj.is_airborne = true thrown_obj.position_z = 2.5 thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale - # Call on_thrown if available - if thrown_obj.has_method("on_thrown"): + # Call on_thrown if available (check validity first) + # Note: For bombs, this will convert to projectile and free the object + if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) + on_thrown_called = true + # Check if object was freed (bomb conversion) + if not is_instance_valid(thrown_obj): + thrown_obj = null - # ⚡ Delay collision re-enable to prevent self-collision - await get_tree().create_timer(0.1).timeout + # ⚡ Delay collision re-enable to prevent self-collision (only if object still exists) if thrown_obj and is_instance_valid(thrown_obj): - thrown_obj.set_collision_layer_value(2, true) - thrown_obj.set_collision_mask_value(1, true) - thrown_obj.set_collision_mask_value(2, true) - thrown_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + await get_tree().create_timer(0.1).timeout + if thrown_obj and is_instance_valid(thrown_obj): + thrown_obj.set_collision_layer_value(2, true) + thrown_obj.set_collision_mask_value(1, true) + thrown_obj.set_collision_mask_value(2, true) + thrown_obj.set_collision_mask_value(7, true) # Re-enable wall collision! elif _is_player(thrown_obj): # Player: set position and physics first thrown_obj.global_position = throw_start_pos @@ -3076,20 +3305,59 @@ func _force_throw_held_object(direction: Vector2): thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_mask_value(1, true) thrown_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): + # Other grabbable object - handle like box + thrown_obj.global_position = throw_start_pos + + # Set throw velocity + if "throw_velocity" in thrown_obj: + var obj_weight = thrown_obj.weight if "weight" in thrown_obj else 1.0 + thrown_obj.throw_velocity = throw_direction * throw_force / obj_weight + if "is_frozen" in thrown_obj: + thrown_obj.is_frozen = false + + # Make airborne + if "is_airborne" in thrown_obj: + thrown_obj.is_airborne = true + thrown_obj.position_z = 2.5 + thrown_obj.velocity_z = 100.0 + + # Call on_thrown if available + if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): + thrown_obj.on_thrown(self, throw_direction * throw_force) + on_thrown_called = true + # Check if object was freed (bomb conversion) + if not is_instance_valid(thrown_obj): + thrown_obj = null + + # Delay collision re-enable (only if object still exists) + if thrown_obj and is_instance_valid(thrown_obj): + await get_tree().create_timer(0.1).timeout + if thrown_obj and is_instance_valid(thrown_obj): + thrown_obj.set_collision_layer_value(2, true) + thrown_obj.set_collision_mask_value(1, true) + thrown_obj.set_collision_mask_value(2, true) + thrown_obj.set_collision_mask_value(7, true) - if thrown_obj.has_method("on_thrown"): + # Only call on_thrown if it wasn't already called (prevents double-call for bombs) + if not on_thrown_called and thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) # Play throw animation _set_animation("THROW") $SfxThrow.play() - # Sync throw over network - if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - var obj_name = _get_object_name_for_sync(thrown_obj) - _rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name]) - - print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force) + # Sync throw over network (only if object still exists - bombs convert to projectile) + if thrown_obj and is_instance_valid(thrown_obj): + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + var obj_name = _get_object_name_for_sync(thrown_obj) + if obj_name != "": + _rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name]) + + print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force) + else: + # Bomb was converted to projectile (object was freed) + print("Threw bomb (converted to projectile) from ", throw_start_pos, " with force: ", throw_direction * throw_force) func _place_down_object(): if not held_object: @@ -3296,6 +3564,143 @@ func _perform_attack(): can_attack = true is_attacking = false +func _create_bomb_object(): + # Dwarf: Create interactable bomb object that can be lifted/thrown + if not is_multiplayer_authority(): + return + + # Consume one bomb + if not character_stats or not character_stats.equipment.has("offhand"): + return + + var offhand_item = character_stats.equipment["offhand"] + if not offhand_item or offhand_item.weapon_type != Item.WeaponType.BOMB or offhand_item.quantity <= 0: + return + + offhand_item.quantity -= 1 + var remaining = offhand_item.quantity + if offhand_item.quantity <= 0: + character_stats.equipment["offhand"] = null + if character_stats: + character_stats.character_changed.emit(character_stats) + + # Load interactable object scene + var interactable_object_scene = load("res://scenes/interactable_object.tscn") + if not interactable_object_scene: + push_error("ERROR: Could not load interactable_object scene!") + return + + # Spawn bomb object at player position + var entities_node = get_parent() + if not entities_node: + entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities") + if not entities_node: + push_error("ERROR: Could not find Entities node!") + return + + var bomb_obj = interactable_object_scene.instantiate() + bomb_obj.name = "BombObject_" + str(Time.get_ticks_msec()) + bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front + + # Set multiplayer authority + if multiplayer.has_multiplayer_peer(): + bomb_obj.set_multiplayer_authority(get_multiplayer_authority()) + + entities_node.add_child(bomb_obj) + + # Setup as bomb object + bomb_obj.setup_bomb() + + # Immediately grab it + held_object = bomb_obj + grab_offset = bomb_obj.position - position + grab_distance = global_position.distance_to(bomb_obj.global_position) + initial_grab_position = bomb_obj.global_position + initial_player_position = global_position + + # Disable collision + bomb_obj.set_collision_layer_value(2, false) + bomb_obj.set_collision_mask_value(1, false) + bomb_obj.set_collision_mask_value(2, false) + bomb_obj.set_collision_mask_value(7, true) # Keep wall collision + + # Notify object it's being grabbed + if bomb_obj.has_method("on_grabbed"): + bomb_obj.on_grabbed(self) + + # Immediately lift the bomb (Dwarf lifts it directly) + # Set is_lifting BEFORE calling _lift_object to prevent it from being reset + is_lifting = true + is_pushing = false + + # Freeze the bomb + if "is_frozen" in bomb_obj: + bomb_obj.is_frozen = true + + # Call on_lifted if available + if bomb_obj.has_method("on_lifted"): + bomb_obj.on_lifted(self) + + # Play lift animation + _set_animation("LIFT") + + # Sync to network + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + var obj_name = _get_object_name_for_sync(bomb_obj) + if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree(): + _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting + + print(name, " created bomb object! Remaining bombs: ", remaining) + + # Sync grab to network + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + var obj_name = _get_object_name_for_sync(bomb_obj) + if obj_name != "": + _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting + +func _throw_bomb(_target_position: Vector2): + # Legacy function - kept for Human/Elf if needed, but Dwarf uses _create_bomb_object now + # This is now unused for Dwarf but kept for compatibility + pass + +func _place_bomb(target_position: Vector2): + # Human/Elf places bomb (no throw, just spawns at target) + if not attack_bomb_scene: + return + + # Only authority can spawn bombs + if not is_multiplayer_authority(): + return + + # Find valid target position + var game_world = get_tree().get_first_node_in_group("game_world") + var valid_target_pos = target_position + if game_world and game_world.has_method("_get_valid_spell_target_position"): + var found_pos = game_world._get_valid_spell_target_position(target_position) + if found_pos != Vector2.ZERO: + valid_target_pos = found_pos + else: + print(name, " cannot place bomb - no valid target position") + return + + # Spawn bomb at target position + var bomb = attack_bomb_scene.instantiate() + get_parent().add_child(bomb) + bomb.global_position = valid_target_pos + + # Set multiplayer authority + if multiplayer.has_multiplayer_peer(): + bomb.set_multiplayer_authority(get_multiplayer_authority()) + + # Setup bomb without throw (placed directly) + bomb.setup(valid_target_pos, self, Vector2.ZERO, false) # false = not thrown + + # Sync bomb spawn to other clients + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_place_bomb", [valid_target_pos]) + + print(name, " placed bomb!") + func _cast_flame_spell(target_position: Vector2): # Cast flame spell at target position (grid-locked cursor) # If target is blocked, find closest valid position @@ -3602,6 +4007,121 @@ func _clear_spell_charge_tint(): for key in keys_to_remove: original_sprite_tints.erase(key) +func _apply_bow_charge_tint(): + # Apply pulsing white tint to all sprite layers when fully charged using shader parameters + # Pulse between original tint and bow charge tint (white) + # IMPORTANT: Only apply to THIS player's sprites (not other players) + if not is_charging_bow: + return + + var sprites = [ + {"sprite": sprite_body, "name": "body"}, + {"sprite": sprite_boots, "name": "boots"}, + {"sprite": sprite_armour, "name": "armour"}, + {"sprite": sprite_facial_hair, "name": "facial_hair"}, + {"sprite": sprite_hair, "name": "hair"}, + {"sprite": sprite_eyes, "name": "eyes"}, + {"sprite": sprite_eyelashes, "name": "eyelashes"}, + {"sprite": sprite_addons, "name": "addons"}, + {"sprite": sprite_headgear, "name": "headgear"} + ] + + # Calculate pulse value (0.0 to 1.0) using sine wave + var pulse_value = (sin(bow_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 + + for sprite_data in sprites: + var sprite = sprite_data.sprite + var sprite_name = sprite_data.name + + # Double-check sprite belongs to this player instance + if not sprite or not is_instance_valid(sprite): + continue + + # Verify sprite is a child of this player node + if sprite.get_parent() != self and not is_ancestor_of(sprite): + continue + + if sprite.material and sprite.material is ShaderMaterial: + var shader_material = sprite.material as ShaderMaterial + + # Store original tint if not already stored (use unique key per player) + var tint_key = str(get_instance_id()) + "_bow_" + sprite_name + if not tint_key in original_sprite_tints: + # Try to get the current tint parameter value + var original_tint_param = shader_material.get_shader_parameter("tint") + if original_tint_param is Vector4: + # Convert Vector4 to Color + original_sprite_tints[tint_key] = Color(original_tint_param.x, original_tint_param.y, original_tint_param.z, original_tint_param.w) + elif original_tint_param is Color: + # Already a Color + original_sprite_tints[tint_key] = original_tint_param + else: + # Default to white if no tint parameter or invalid + original_sprite_tints[tint_key] = Color.WHITE + + # Get original tint + var original_tint = original_sprite_tints.get(tint_key, Color.WHITE) + + # Calculate fully charged tint (original * bow_charge_tint - white tint) + var full_charged_tint = Color( + original_tint.r * bow_charge_tint.r, + original_tint.g * bow_charge_tint.g, + original_tint.b * bow_charge_tint.b, + original_tint.a * bow_charge_tint.a + ) + + # Interpolate between original and fully charged tint based on pulse + var current_tint = original_tint.lerp(full_charged_tint, pulse_value) + + # Apply the pulsing tint + shader_material.set_shader_parameter("tint", Vector4(current_tint.r, current_tint.g, current_tint.b, current_tint.a)) + +func _clear_bow_charge_tint(): + # Restore original tint values for all sprite layers + # IMPORTANT: Only restore THIS player's sprites (not other players) + var sprites = [ + {"sprite": sprite_body, "name": "body"}, + {"sprite": sprite_boots, "name": "boots"}, + {"sprite": sprite_armour, "name": "armour"}, + {"sprite": sprite_facial_hair, "name": "facial_hair"}, + {"sprite": sprite_hair, "name": "hair"}, + {"sprite": sprite_eyes, "name": "eyes"}, + {"sprite": sprite_eyelashes, "name": "eyelashes"}, + {"sprite": sprite_addons, "name": "addons"}, + {"sprite": sprite_headgear, "name": "headgear"} + ] + + var instance_id_str = str(get_instance_id()) + var keys_to_remove = [] + + for sprite_data in sprites: + var sprite = sprite_data.sprite + var sprite_name = sprite_data.name + + # Double-check sprite belongs to this player instance + if not sprite or not is_instance_valid(sprite): + continue + + # Verify sprite is a child of this player node + if sprite.get_parent() != self and not is_ancestor_of(sprite): + continue + + if sprite.material and sprite.material is ShaderMaterial: + var shader_material = sprite.material as ShaderMaterial + + # Use unique key per player (with "_bow_" prefix to separate from spell tints) + var tint_key = instance_id_str + "_bow_" + sprite_name + + # Restore original tint if we stored it + if tint_key in original_sprite_tints: + var original_tint = original_sprite_tints[tint_key] + shader_material.set_shader_parameter("tint", Vector4(original_tint.r, original_tint.g, original_tint.b, original_tint.a)) + keys_to_remove.append(tint_key) + + # Clear stored tints for this player only + for key in keys_to_remove: + original_sprite_tints.erase(key) + @rpc("any_peer", "reliable") func _sync_spell_charge_start(): # Sync spell charge start to other clients @@ -3616,8 +4136,19 @@ func _sync_spell_charge_end(): # Sync spell charge end to other clients if not is_multiplayer_authority(): is_charging_spell = false + spell_incantation_played = false _stop_spell_charge_particles() _clear_spell_charge_tint() + + # Return to IDLE animation + _set_animation("IDLE") + + # Stop spell charging sounds + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + print(name, " (synced) ended charging spell") func _apply_burn_debuff(): @@ -4040,8 +4571,48 @@ func _sync_bow_charge_end(): # Sync bow charge end to other clients if not is_multiplayer_authority(): is_charging_bow = false + bow_charge_tint_pulse_time = 0.0 + _clear_bow_charge_tint() print(name, " (synced) ended charging bow") +@rpc("any_peer", "reliable") +func _sync_place_bomb(target_pos: Vector2): + # Sync bomb placement to other clients + if not is_multiplayer_authority(): + if not attack_bomb_scene: + return + + # Spawn bomb at target position + var bomb = attack_bomb_scene.instantiate() + get_parent().add_child(bomb) + bomb.global_position = target_pos + + # Setup bomb without throw (placed directly) + bomb.setup(target_pos, self, Vector2.ZERO, false) # false = not thrown + + print(name, " (synced) placed bomb at ", target_pos) + +@rpc("any_peer", "reliable") +func _sync_throw_bomb(bomb_pos: Vector2, throw_force: Vector2): + # Sync bomb throw to other clients + if not is_multiplayer_authority(): + if not attack_bomb_scene: + return + + # Spawn bomb projectile at position + var bomb = attack_bomb_scene.instantiate() + get_parent().add_child(bomb) + bomb.global_position = bomb_pos + + # Setup bomb with throw physics + bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown + + # Make sure bomb sprite is visible + if bomb.has_node("Sprite2D"): + bomb.get_node("Sprite2D").visible = true + + print(name, " (synced) threw bomb from ", bomb_pos) + @rpc("any_peer", "reliable") func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_name: String): # Sync throw to all clients (RPC sender already threw on their side) @@ -4555,10 +5126,50 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool if is_charging_bow: is_charging_bow = false + # Clear bow charge tint + _clear_bow_charge_tint() + # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() + # Check if spell charging should be cancelled (50% chance, using gameworld seed) + if is_charging_spell: + var should_cancel = false + var world_node = get_tree().get_first_node_in_group("game_world") + if world_node and "dungeon_seed" in world_node: + # Use deterministic RNG based on gameworld seed and player position/time + var rng_seed = world_node.dungeon_seed + rng_seed += int(global_position.x) * 1000 + int(global_position.y) + rng_seed += int(Time.get_ticks_msec() / 100.0) # Add time component for uniqueness + var rng = RandomNumberGenerator.new() + rng.seed = rng_seed + should_cancel = rng.randf() < 0.5 # 50% chance + else: + # Fallback to regular random if no gameworld seed + should_cancel = randf() < 0.5 + + if should_cancel: + is_charging_spell = false + spell_incantation_played = false + _stop_spell_charge_particles() + _clear_spell_charge_tint() + + # Return to IDLE animation + _set_animation("IDLE") + + # Stop spell charging sounds + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + + # Sync spell charge end to other clients + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() + + print(name, " spell charging cancelled due to damage!") + # Check for dodge chance (based on DEX) var _was_dodged = false if character_stats: @@ -5053,6 +5664,29 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): _apply_appearance_to_sprites() print("Elf player ", name, " (remote) received short bow and 3 arrows via race sync") + "Dwarf": + character_stats.setEars(0) + # Give Dwarf starting bomb to remote players + # (Authority players get this in _setup_player_appearance) + # Check if equipment is missing - give it regardless of whether race changed + if not is_multiplayer_authority(): + var needs_equipment = false + if character_stats.equipment["offhand"] == null: + needs_equipment = true + else: + # Check if offhand is not a bomb + var offhand = character_stats.equipment["offhand"] + if not offhand or offhand.item_name != "Bomb" or offhand.quantity < 1: + needs_equipment = true + + if needs_equipment: + var starting_bomb = ItemDatabase.create_item("bomb") + if starting_bomb: + starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start + character_stats.equipment["offhand"] = starting_bomb + _apply_appearance_to_sprites() + print("Dwarf player ", name, " (remote) received 5 bombs via race sync") + "Human": character_stats.setEars(0) # Give Human starting spellbook (Tome of Flames) to remote players