diff --git a/src/assets/fonts/dmg_numbers.png b/src/assets/fonts/dmg_numbers.png index 66f9e0e..d721530 100644 Binary files a/src/assets/fonts/dmg_numbers.png and b/src/assets/fonts/dmg_numbers.png differ diff --git a/src/assets/gfx/fx/shade_spell_effects.png b/src/assets/gfx/fx/shade_spell_effects.png new file mode 100644 index 0000000..63e9d57 Binary files /dev/null and b/src/assets/gfx/fx/shade_spell_effects.png differ diff --git a/src/assets/gfx/fx/shade_spell_effects.png.import b/src/assets/gfx/fx/shade_spell_effects.png.import new file mode 100644 index 0000000..f284c55 --- /dev/null +++ b/src/assets/gfx/fx/shade_spell_effects.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bf158atxi7ucy" +path="res://.godot/imported/shade_spell_effects.png-1e71e6c58b206f9920df29b69ad9b76f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/fx/shade_spell_effects.png" +dest_files=["res://.godot/imported/shade_spell_effects.png-1e71e6c58b206f9920df29b69ad9b76f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/scenes/attack_bomb.tscn b/src/scenes/attack_bomb.tscn index 2ee2b16..d8e9709 100644 --- a/src/scenes/attack_bomb.tscn +++ b/src/scenes/attack_bomb.tscn @@ -6,6 +6,9 @@ [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"] +[ext_resource type="AudioStream" uid="uid://nks0upmnsatn" path="res://assets/audio/sfx/ambience/debris-rocks-01.wav.mp3" id="7_h6264"] +[ext_resource type="AudioStream" uid="uid://dpwa2spwtc055" path="res://assets/audio/sfx/ambience/debris-rocks-02.wav.mp3" id="8_fa1rq"] +[ext_resource type="AudioStream" uid="uid://cxl1ltxeqd4ye" path="res://assets/audio/sfx/ambience/debris-rocks-03.wav.mp3" id="9_haynv"] [sub_resource type="Gradient" id="Gradient_shadow"] colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) @@ -17,7 +20,7 @@ fill_from = Vector2(0.5, 0.5) fill_to = Vector2(0.8, 0.8) [sub_resource type="CircleShape2D" id="CircleShape2D_bomb"] -radius = 8.0 +radius = 4.5 [sub_resource type="CircleShape2D" id="CircleShape2D_collection"] radius = 16.0 @@ -63,6 +66,14 @@ fill = 1 fill_from = Vector2(0.5, 0.5) fill_to = Vector2(0.9102564, 0.9188034) +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_tqihf"] +playback_mode = 1 +random_pitch = 1.0376596 +streams_count = 3 +stream_0/stream = ExtResource("7_h6264") +stream_1/stream = ExtResource("8_fa1rq") +stream_2/stream = ExtResource("9_haynv") + [node name="Bomb" type="CharacterBody2D" unique_id=367943636] collision_layer = 2 motion_mode = 1 @@ -75,6 +86,7 @@ scale = Vector2(0.1, 0.1) texture = SubResource("GradientTexture2D_shadow") [node name="Sprite2D" type="Sprite2D" parent="." unique_id=818862430] +position = Vector2(0, -1) texture = ExtResource("2_ng1nl") hframes = 20 vframes = 14 @@ -95,7 +107,7 @@ 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) +debug_color = Color(0.29747584, 0.70196074, 0.6174988, 0.41960785) [node name="CollectionArea" type="Area2D" parent="." unique_id=1088408959] collision_layer = 0 @@ -103,7 +115,7 @@ 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) +debug_color = Color(0.70196074, 0.51966184, 0.5406604, 0.41960785) [node name="SfxFuse" type="AudioStreamPlayer2D" parent="." unique_id=1095147141] stream = ExtResource("3_fuse") @@ -125,14 +137,14 @@ bus = &"Sfx" [node name="FuseParticles" type="GPUParticles2D" parent="." unique_id=1234567890] z_index = 2 -position = Vector2(6, -5) +position = Vector2(6.44, -6.46) 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) +position = Vector2(6.485, -6.335) enabled = false color = Color(1, 0.4, 0.1, 1) energy = 0.8 @@ -143,3 +155,8 @@ enabled = false color = Color(1, 0.6, 0.2, 1) energy = 2.5 texture = SubResource("GradientTexture2D_explosion_light") + +[node name="SfxDebrisFromParticles" type="AudioStreamPlayer2D" parent="." unique_id=1975206979] +stream = SubResource("AudioStreamRandomizer_tqihf") +attenuation = 1.7411015 +panning_strength = 1.05 diff --git a/src/scenes/explosion_tile_particle.tscn b/src/scenes/explosion_tile_particle.tscn new file mode 100644 index 0000000..40487df --- /dev/null +++ b/src/scenes/explosion_tile_particle.tscn @@ -0,0 +1,24 @@ +[gd_scene format=3 uid="uid://explosion_tile_particle_1"] + +[ext_resource type="Script" path="res://scripts/explosion_tile_particle.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://c4ee36hr5f766" path="res://assets/gfx/RPG DUNGEON VOL 3.png" id="2_dungeon"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_1"] +size = Vector2(7, 7) + +[node name="ExplosionTileParticle" type="CharacterBody2D"] +z_index = 17 +z_as_relative = false +y_sort_enabled = true +collision_layer = 0 +collision_mask = 64 +script = ExtResource("1_script") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +position = Vector2(-3.5, -3.5) +shape = SubResource("RectangleShape2D_1") + +[node name="Sprite2D" type="Sprite2D" parent="."] +texture = ExtResource("2_dungeon") +region_enabled = true +region_rect = Rect2(0, 0, 8, 8) diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 1e5d62a..d0e8034 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -36,6 +36,7 @@ [ext_resource type="AudioStream" uid="uid://d1ut5lnlch0k2" path="res://assets/audio/sfx/weapons/bow/bow_release3.mp3" id="32_jc3p3"] [ext_resource type="AudioStream" uid="uid://bvi00vbftbgc5" path="res://assets/audio/sfx/player/ultra_run/shinespark_start.wav" id="35_bj30b"] [ext_resource type="AudioStream" uid="uid://0xm3gyh8051h" path="res://assets/audio/sfx/wizard/incantations/indignation.mp3" id="36_jc3p3"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="37_hax0n"] [sub_resource type="Gradient" id="Gradient_wqfne"] colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) @@ -293,6 +294,78 @@ random_pitch = 1.0630184 streams_count = 1 stream_0/stream = ExtResource("31_487ah") +[sub_resource type="Animation" id="Animation_t4otl"] +length = 0.001 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [2037] +} + +[sub_resource type="Animation" id="Animation_j2b1d"] +resource_name = "fire_charging" +length = 0.4 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.10000002, 0.13333336, 0.16666669, 0.20000002, 0.23333335, 0.26666668, 0.30000004, 0.33333337, 0.3666667, 0.4, 0.43333337, 0.46666673, 0.5, 0.53333336), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053] +} + +[sub_resource type="Animation" id="Animation_cs1tg"] +resource_name = "fire_ready" +length = 0.6 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.10000002, 0.13333336, 0.16666669, 0.20000002, 0.23333335, 0.26666668, 0.30000004, 0.33333337, 0.3666667, 0.4, 0.43333337, 0.46666673, 0.5, 0.53333336), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053] +} + +[sub_resource type="Animation" id="Animation_hax0n"] +resource_name = "idle" +length = 0.1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [0] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_2dvfe"] +_data = { +&"RESET": SubResource("Animation_t4otl"), +&"fire_charging": SubResource("Animation_j2b1d"), +&"fire_ready": SubResource("Animation_cs1tg"), +&"idle": SubResource("Animation_hax0n") +} + [node name="Player" type="CharacterBody2D" unique_id=937429705] collision_mask = 67 motion_mode = 1 @@ -493,3 +566,13 @@ volume_db = 5.729 attenuation = 7.727487 panning_strength = 1.04 bus = &"Sfx" + +[node name="IncantationSprite" type="Sprite2D" parent="." unique_id=1655944614] +texture = ExtResource("37_hax0n") +hframes = 105 +vframes = 79 +frame = 2037 + +[node name="AnimationIncantation" type="AnimationPlayer" parent="." unique_id=17658820] +libraries/ = SubResource("AnimationLibrary_2dvfe") +autoplay = &"idle" diff --git a/src/scenes/sword_slash.tscn b/src/scenes/sword_slash.tscn index 40fa69d..1c9fc12 100644 --- a/src/scenes/sword_slash.tscn +++ b/src/scenes/sword_slash.tscn @@ -1,28 +1,26 @@ [gd_scene format=3 uid="uid://bvxp7yw8q1k2l"] -[ext_resource type="Script" path="res://scripts/sword_slash.gd" id="1_sword"] -[ext_resource type="Texture2D" uid="uid://hib38y541eog" path="res://assets/gfx/items_n_shit.png" id="2_texture"] +[ext_resource type="Script" uid="uid://bqxbhjq2b4ram" path="res://scripts/sword_slash.gd" id="1_sword"] +[ext_resource type="Texture2D" uid="uid://dkpritx47nd4m" path="res://assets/gfx/pickups/items_n_shit.png" id="2_e3omh"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_slash"] size = Vector2(120, 60) -[node name="SwordSlash" type="Node2D"] +[node name="SwordSlash" type="Node2D" unique_id=1348241278] script = ExtResource("1_sword") -[node name="Sprite2D" type="Sprite2D" parent="."] -texture = ExtResource("2_texture") +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1244548324] rotation = 3.14159 scale = Vector2(3, 3) +texture = ExtResource("2_e3omh") hframes = 20 vframes = 14 frame = 60 -region_enabled = false -[node name="Area2D" type="Area2D" parent="."] +[node name="Area2D" type="Area2D" parent="." unique_id=1569887807] collision_layer = 4 collision_mask = 3 -[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"] +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1035191880] position = Vector2(60, 0) shape = SubResource("RectangleShape2D_slash") - diff --git a/src/scripts/attack_arrow.gd b/src/scripts/attack_arrow.gd index aabd41e..1c4f8d6 100644 --- a/src/scripts/attack_arrow.gd +++ b/src/scripts/attack_arrow.gd @@ -288,14 +288,14 @@ func _stick_to_wall(): stick_timer = 0.0 arrow_area.set_deferred("monitoring", false) -@rpc("any_peer", "call_local", "reliable") -func _sync_arrow_collected(): - # Sync arrow collection across network - mark as collected and remove - if not is_collected: - is_collected = true - print(name, " arrow collected (synced)") - # Queue free on next frame to avoid issues - call_deferred("queue_free") +func _sync_arrow_collected_via_gameworld(arrow_name: String): + # Route sync through game_world (RPC on arrow caused "node not found" on host). + # Collector already added to inventory and will queue_free locally. + if arrow_name.is_empty(): + return + var gw = get_tree().get_first_node_in_group("game_world") + if gw and gw.has_method("_sync_arrow_collected") and multiplayer.has_multiplayer_peer(): + gw._sync_arrow_collected.rpc(arrow_name) @rpc("any_peer", "call_local", "reliable") func _sync_arrow_hit(target_name: String): @@ -409,20 +409,19 @@ func _on_collection_area_body_entered(body: Node2D): print("ERROR: body.character_stats is invalid when trying to collect arrow") return + # Capture stable arrow name for sync (route through game_world to avoid RPC path issues) + var arrow_name = name + # Check if player has arrows equipped in offhand var offhand_item = body.character_stats.equipment.get("offhand", null) if offhand_item and is_instance_valid(offhand_item) and offhand_item.item_name == "Arrow": is_collected = true - - # Sync arrow collection to all clients - if multiplayer.has_multiplayer_peer(): - _sync_arrow_collected.rpc() - $SfxPickup.play() # Add directly to equipped arrows offhand_item.quantity += 1 body.character_stats.character_changed.emit(body.character_stats) print(body.name, " collected arrow from wall! Total arrows: ", offhand_item.quantity) + _sync_arrow_collected_via_gameworld(arrow_name) await $SfxPickup.finished queue_free() return @@ -436,16 +435,12 @@ func _on_collection_area_body_entered(body: Node2D): var new_arrow = ItemDatabase.create_item("arrow") if new_arrow and is_instance_valid(new_arrow): is_collected = true - - # Sync arrow collection to all clients - if multiplayer.has_multiplayer_peer(): - _sync_arrow_collected.rpc() - $SfxPickup.play() new_arrow.quantity = 1 body.character_stats.equipment["offhand"] = new_arrow body.character_stats.character_changed.emit(body.character_stats) print(body.name, " collected arrow and re-equipped to offhand!") + _sync_arrow_collected_via_gameworld(arrow_name) await $SfxPickup.finished queue_free() return @@ -457,22 +452,14 @@ func _on_collection_area_body_entered(body: Node2D): return inventory_arrow.quantity = 1 - if not body.character_stats.has_method("add_item_to_inventory"): - print("ERROR: character_stats missing add_item_to_inventory method") + if not body.character_stats.has_method("add_item"): + print("ERROR: character_stats missing add_item method") return - var success = body.character_stats.add_item_to_inventory(inventory_arrow) - if success: - is_collected = true - - # Sync arrow collection to all clients - if multiplayer.has_multiplayer_peer(): - _sync_arrow_collected.rpc() - - $SfxPickup.play() - print(body.name, " collected arrow from wall into inventory!") - await $SfxPickup.finished - queue_free() - else: - print(body.name, " inventory full, couldn't collect arrow") - # Don't remove arrow if inventory is full + body.character_stats.add_item(inventory_arrow) + is_collected = true + $SfxPickup.play() + print(body.name, " collected arrow from wall into inventory!") + _sync_arrow_collected_via_gameworld(arrow_name) + await $SfxPickup.finished + queue_free() diff --git a/src/scripts/attack_bomb.gd b/src/scripts/attack_bomb.gd index 304c17e..ca462a9 100644 --- a/src/scripts/attack_bomb.gd +++ b/src/scripts/attack_bomb.gd @@ -5,7 +5,7 @@ extends CharacterBody2D @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 +@export var screenshake_strength: float = 18.0 # Base screenshake strength (stronger) var player_owner: Node = null var is_fused: bool = false @@ -21,6 +21,7 @@ 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 @@ -43,6 +44,10 @@ var collection_delay: float = 0.2 # Can be collected after 0.2 seconds # 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 @@ -62,7 +67,17 @@ func _ready(): if explosion_sprite: explosion_sprite.visible = false - # Setup damage area (48x48 radius) + # Setup shadow (like interactable - visible, under bomb) + if shadow: + shadow.visible = true + shadow.modulate = Color(0, 0, 0, 0.5) + shadow.z_index = -1 + + # 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: @@ -70,7 +85,7 @@ func _ready(): 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) + # Start fuse if not thrown (placed bomb starts fusing immediately; thrown bombs start fuse on land) if not is_thrown: _start_fuse() @@ -88,6 +103,12 @@ func setup(target_position: Vector2, owner_player: Node, throw_force: Vector2 = 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 @@ -157,17 +178,19 @@ func _physics_process(delta): velocity_z -= gravity_z * delta position_z += velocity_z * delta - # Update sprite position based on height + # 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 + # 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.a = 0.5 - (position_z / 100.0) * 0.3 + shadow.modulate = Color(0, 0, 0, 0.5 - (position_z / 100.0) * 0.3) # Apply throw velocity velocity = throw_velocity @@ -176,13 +199,15 @@ func _physics_process(delta): if position_z <= 0.0: _land() else: - # On ground - reset sprite/shadow + # 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.a = 0.5 + shadow.modulate = Color(0, 0, 0, 0.5) # Apply friction if on ground if not is_airborne: @@ -211,7 +236,7 @@ func _physics_process(delta): if fuse_timer >= collection_delay: can_be_collected = true if collection_area: - collection_area.monitoring = true + collection_area.set_deferred("monitoring", true) func _land(): is_airborne = false @@ -228,9 +253,11 @@ func _explode(): is_exploding = true - # Hide bomb sprite, show explosion + # 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 @@ -263,6 +290,11 @@ func _explode(): # 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) @@ -332,6 +364,76 @@ func _deal_explosion_damage(): 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") @@ -359,9 +461,9 @@ func _cause_screenshake(): 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 + # Apply screenshake (longer duration for bigger boom) if game_world.has_method("add_screenshake"): - game_world.add_screenshake(shake_strength, 0.3) # 0.3 second duration + 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) @@ -390,6 +492,14 @@ func on_grabbed(by_player): 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: @@ -404,6 +514,16 @@ func on_grabbed(by_player): 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() diff --git a/src/scripts/attack_spell_flame.gd b/src/scripts/attack_spell_flame.gd index 5d1cc59..3910888 100644 --- a/src/scripts/attack_spell_flame.gd +++ b/src/scripts/attack_spell_flame.gd @@ -204,6 +204,15 @@ func _deal_periodic_damage(): else: print("Flame spell hit enemy: ", body.name, " for ", final_damage, " damage!") + # Destroy wooden interactable objects (box, barrel, pot, etc.) – they burn and break + elif body.is_in_group("interactable_object") and body.has_method("take_fire_damage"): + if "is_being_held" in body and body.is_being_held: + continue # Don't break objects while held + var attacker_pos = player_owner.global_position if player_owner else global_position + body.take_fire_damage(final_damage, attacker_pos) + if is_first_hit: + print("Flame spell burning wooden object: ", body.name, " for ", final_damage, " damage!") + func _on_body_entered(_body): # Track bodies that enter the area (for periodic damage) # Don't add to hit_targets here - we want to deal damage multiple times diff --git a/src/scripts/chat_ui.gd b/src/scripts/chat_ui.gd index 1c99794..f72e170 100644 --- a/src/scripts/chat_ui.gd +++ b/src/scripts/chat_ui.gd @@ -31,6 +31,7 @@ var background: ColorRect = null var metropolis_font: FontFile = null func _ready(): + add_to_group("chat_ui") network_manager = get_node_or_null("/root/NetworkManager") # Load Metropolis font diff --git a/src/scripts/explosion_tile_particle.gd b/src/scripts/explosion_tile_particle.gd new file mode 100644 index 0000000..51d28f2 --- /dev/null +++ b/src/scripts/explosion_tile_particle.gd @@ -0,0 +1,60 @@ +extends CharacterBody2D +# Tile debris from bomb explosion - flies out, bounces off walls, fades + +var angular_velocity: float = 0.0 +var fade_timer: float = 0.0 +var fading: bool = false +const FADE_DELAY: float = 0.8 +const BOUNCE_DAMP: float = 0.82 # Keep more speed after bounce - fly around more + +# Z-axis physics (fly up, then fall back down with gravity) +var position_z: float = 0.0 +var velocity_z: float = 0.0 +var acceleration_z: float = -500.0 # Downward gravity + +func _physics_process(delta: float) -> void: + if fading: + return + + # Slow down over time (slower decay so they keep flying longer) + velocity = velocity.lerp(Vector2.ZERO, delta * 1.8) + if abs(angular_velocity) > 0.1: + rotation += angular_velocity * delta + angular_velocity = lerp(angular_velocity, 0.0, delta * 1.5) + + # Z physics: gravity, then integrate + velocity_z += acceleration_z * delta + position_z += velocity_z * delta + if position_z <= 0.0: + position_z = 0.0 + velocity_z = 0.0 + + move_and_slide() + + # Bounce off walls (tilemap uses layer 7 = bit 64) + for i in get_slide_collision_count(): + var col = get_slide_collision(i) + if col and col.get_collider() is CollisionObject2D: + var wall = col.get_collider() as CollisionObject2D + if wall.get_collision_layer_value(7): # Layer 7 = walls + velocity = velocity.bounce(col.get_normal()) * BOUNCE_DAMP + angular_velocity *= -0.6 + break + + # Visual: raise sprite when airborne, scale up a little when flying upward + var spr = get_node_or_null("Sprite2D") + if spr: + spr.position.y = -position_z * 0.5 + var scale_up = 1.0 + (position_z / 60.0) * 0.25 # Slightly bigger when higher + spr.scale = Vector2.ONE * clampf(scale_up, 1.0, 1.35) + + # Fade after delay + fade_timer += delta + if fade_timer >= FADE_DELAY: + fading = true + if spr: + var t = create_tween() + t.tween_property(spr, "modulate:a", 0.0, 0.4) + t.tween_callback(queue_free) + else: + queue_free() diff --git a/src/scripts/explosion_tile_particle.gd.uid b/src/scripts/explosion_tile_particle.gd.uid new file mode 100644 index 0000000..6cdcd0e --- /dev/null +++ b/src/scripts/explosion_tile_particle.gd.uid @@ -0,0 +1 @@ +uid://c3ae81av1j4qr diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 35dc274..98d7d00 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -2044,8 +2044,8 @@ func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool: if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: return false - # Check if it's a floor tile (grid value 1) or corridor (grid value 3) - if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 3: + # Check if it's a floor (1), door (2), or corridor (3) tile - same as walkable + if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 2 and grid[tile_x][tile_y] != 3: return false # Check if there's a wall between player and target using raycast @@ -6661,6 +6661,20 @@ func _broadcast_object_break(obj_name: String): if is_inside_tree() and multiplayer.has_multiplayer_peer() and multiplayer.is_server(): _rpc_to_ready_peers("_sync_object_break", [obj_name]) +@rpc("any_peer", "reliable") +func _sync_arrow_collected(arrow_name: String): + # Route arrow collection through game_world to avoid node path issues (RPC was on arrow). + # Collector already added to inventory, played sound, and queue_freed locally. + # Other peers find arrow in Entities by stable name and remove it. + if not is_inside_tree() or arrow_name.is_empty(): + return + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + var arrow = entities_node.get_node_or_null(arrow_name) + if arrow and is_instance_valid(arrow): + arrow.call_deferred("queue_free") + func _create_level_complete_ui_programmatically() -> Node: # Create level complete UI programmatically var canvas_layer = CanvasLayer.new() diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 4108d32..01b1c1b 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -408,6 +408,28 @@ func can_be_thrown() -> bool: func can_be_destroyed() -> bool: return is_destroyable +func _is_wooden_burnable() -> bool: + var t = object_type if object_type != "" else _get_configured_object_type() + return t in ["Box", "Pot", "LiftableBarrel", "PushableBarrel", "PushableHighBox"] + +func take_fire_damage(amount: float, _attacker_position: Vector2) -> void: + if not is_destroyable or is_broken or not _is_wooden_burnable(): + return + health -= amount + if health > 0: + return + var game_world = get_tree().get_first_node_in_group("game_world") + if multiplayer.has_multiplayer_peer(): + if multiplayer.is_server(): + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [name]) + _break_into_pieces() + else: + if game_world and game_world.has_method("_sync_object_break"): + game_world._sync_object_break.rpc_id(1, name) + else: + _break_into_pieces() + func on_grabbed(by_player): # Special handling for chests - open instead of grab if object_type == "Chest" and not is_chest_opened: @@ -526,9 +548,9 @@ func _convert_to_bomb_projectile(by_player, force: Vector2): if bomb.has_node("Sprite2D"): bomb.get_node("Sprite2D").visible = true - # Sync bomb throw to other clients + # Sync bomb throw to other clients (pass our name so they can free the lifted bomb) 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]) + by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force]) # Remove the interactable object queue_free() diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index 7810ecb..ef7106b 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -514,8 +514,9 @@ func _update_ui(): sprite.scale = Vector2(2.0, 2.0) # 2x size as requested button.add_child(sprite) - # Add quantity label if item can have multiple (like arrows) - if equipped_item.can_have_multiple_of and equipped_item.quantity > 1: + # Add quantity label if item can have multiple (like arrows, bombs) + var show_qty = equipped_item.can_have_multiple_of and (equipped_item.quantity > 1 or equipped_item.weapon_type == Item.WeaponType.BOMB) + if show_qty: var quantity_label = Label.new() quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP @@ -530,6 +531,7 @@ func _update_ui(): font_file.font_data = dmg_font_resource quantity_label.add_theme_font_override("font", font_file) quantity_label.add_theme_font_size_override("font_size", 16) + quantity_label.add_theme_color_override("font_color", Color.GREEN) quantity_label.z_index = 100 # High z-index to show above item sprite quantity_label.z_as_relative = false # Absolute z-index button.add_child(quantity_label) @@ -610,6 +612,7 @@ func _update_ui(): font_file.font_data = dmg_font_resource quantity_label.add_theme_font_override("font", font_file) quantity_label.add_theme_font_size_override("font_size", 16) + quantity_label.add_theme_color_override("font_color", Color.GREEN) quantity_label.z_index = 100 # High z-index to show above item sprite button.add_child(quantity_label) diff --git a/src/scripts/item.gd b/src/scripts/item.gd index 6df034c..c602c23 100644 --- a/src/scripts/item.gd +++ b/src/scripts/item.gd @@ -40,24 +40,24 @@ var use_function = null var item_name: String = "Red Apple" var description: String = "Restores 5 HP" -var spritePath: String = "res://assets/gfx/items_n_shit.png" +var spritePath: String = "res://assets/gfx/pickups/items_n_shit.png" var equipmentPath: String = "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Basic Body/BasicRed.png" var colorReplacements: Array = [] -var spriteFrames:Vector2i = Vector2i(20,14) -var spriteFrame:int = 0 -var modifiers: Dictionary = { "hp": +20 } +var spriteFrames: Vector2i = Vector2i(20, 14) +var spriteFrame: int = 0 +var modifiers: Dictionary = {"hp": + 20} var duration: float = 0 var buy_cost: int = 10 var sell_worth: int = 3 -var sellable:bool = true +var sellable: bool = true var item_type: ItemType = ItemType.Restoration var equipment_type: EquipmentType = EquipmentType.NONE var weapon_type: WeaponType = WeaponType.NONE -var two_handed:bool = false +var two_handed: bool = false var quantity = 1 -var can_have_multiple_of:bool = false -var weight: float = 2.0 # Item weight for encumbrance system (default 2.0 for equipment) +var can_have_multiple_of: bool = false +var weight: float = 2.0 # Item weight for encumbrance system (default 2.0 for equipment) func save(): var json = { diff --git a/src/scripts/player.gd b/src/scripts/player.gd index e819ff6..4de9182 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -73,8 +73,10 @@ var bow_charge_percentage: float = 1.0 # 0.5, 0.75, or 1.0 based on charge time var is_charging_spell: bool = false # True when holding grab with spellbook var spell_charge_start_time: float = 0.0 var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second) +var use_spell_charge_particles: bool = false # If true, use red_star particles; if false, use AnimationIncantation var spell_charge_particles: Node2D = null # Particle system for charging var spell_charge_particle_timer: float = 0.0 # Timer for spawning particles +var spell_incantation_fire_ready_shown: bool = false # Track when we've switched to fire_ready var spell_charge_tint: Color = Color(2.0, 0.2, 0.2, 2.0) # Tint color when fully charged 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 @@ -92,6 +94,10 @@ var burn_damage_timer: float = 0.0 # Timer for burn damage ticks var movement_lock_timer: float = 0.0 # Lock movement when bow is released var direction_lock_timer: float = 0.0 # Lock facing direction when attacking var locked_facing_direction: Vector2 = Vector2.ZERO # Locked facing direction during attack +var damage_direction_lock_timer: float = 0.0 # Lock facing when taking damage (enemies/players) +var damage_direction_lock_duration: float = 0.3 # Duration to block direction change after damage +var empty_bow_shot_attempts: int = 0 +var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync) var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New projectile version var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile @@ -1493,6 +1499,10 @@ func _get_direction_from_vector(vec: Vector2) -> int: # Update facing direction from mouse position (called by GameWorld) func _update_facing_from_mouse(mouse_direction: Vector2): + # Don't update facing when dead + if is_dead: + return + # Only update if using keyboard input (not gamepad) if input_device != -1: return @@ -1505,6 +1515,10 @@ func _update_facing_from_mouse(mouse_direction: Vector2): if direction_lock_timer > 0.0: return + # Don't update if direction is locked (taking damage from enemies/players) + if damage_direction_lock_timer > 0.0: + return + # Mark that mouse control is active (prevents movement keys from overriding attack direction) mouse_control_active = true @@ -1739,6 +1753,12 @@ func _physics_process(delta): if direction_lock_timer <= 0.0: direction_lock_timer = 0.0 + # Update damage direction lock timer (block facing change when taking damage) + if damage_direction_lock_timer > 0.0: + damage_direction_lock_timer -= delta + if damage_direction_lock_timer <= 0.0: + damage_direction_lock_timer = 0.0 + # Update spell charging if is_charging_spell: var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time @@ -1747,6 +1767,7 @@ func _physics_process(delta): # Update particles (spawn and animate) spell_charge_particle_timer += delta _update_spell_charge_particles(charge_progress) + _update_spell_charge_incantation(charge_progress) # Update tint pulse timer when fully charged if charge_progress >= 1.0: @@ -2292,6 +2313,7 @@ func _handle_interactions(): spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_incantation_played = false # Reset flag when starting new charge _start_spell_charge_particles() + _start_spell_charge_incantation() # Play spell charging sound (incantation plays when fully charged) if has_node("SfxSpellCharge"): @@ -2314,6 +2336,7 @@ func _handle_interactions(): is_charging_spell = false spell_incantation_played = false _stop_spell_charge_particles() + _stop_spell_charge_incantation() _clear_spell_charge_tint() # Return to IDLE animation @@ -2351,6 +2374,7 @@ func _handle_interactions(): is_charging_spell = false spell_incantation_played = false _stop_spell_charge_particles() + _stop_spell_charge_incantation() _clear_spell_charge_tint() # This will restore original tints # Stop spell charging sound, but let incantation play to completion @@ -2369,6 +2393,7 @@ func _handle_interactions(): is_charging_spell = false spell_incantation_played = false _stop_spell_charge_particles() + _stop_spell_charge_incantation() _clear_spell_charge_tint() # This will restore original tints # Return to IDLE animation @@ -2393,6 +2418,7 @@ func _handle_interactions(): is_charging_spell = false spell_incantation_played = false _stop_spell_charge_particles() + _stop_spell_charge_incantation() _clear_spell_charge_tint() # Return to IDLE animation @@ -2788,6 +2814,12 @@ func _try_grab(): closest_body = body if closest_body: + # Placed bomb (attack_bomb with fuse): collect to inventory, don't lift + if "is_fused" in closest_body and "can_be_collected" in closest_body and "player_owner" in closest_body: + if closest_body.has_method("can_be_grabbed") and closest_body.can_be_grabbed() and closest_body.has_method("on_grabbed"): + closest_body.on_grabbed(self) + return + held_object = closest_body # Store the initial positions - don't change the grabbed object's position yet! initial_grab_position = closest_body.global_position @@ -3363,9 +3395,31 @@ func _place_down_object(): if not held_object: return - # Place object in front of player based on facing direction (mouse or movement) - var place_pos = _find_closest_place_pos(facing_direction_vector, held_object) var placed_obj = held_object + var place_pos = _find_closest_place_pos(facing_direction_vector, placed_obj) + + # Dwarf dropping bomb: place attack_bomb with fuse lit (explodes if not picked up in time) + if "object_type" in placed_obj and placed_obj.object_type == "Bomb": + if not _can_place_down_at(place_pos, placed_obj): + return + var bomb_name = placed_obj.name + held_object = null + grab_offset = Vector2.ZERO + is_lifting = false + is_pushing = false + placed_obj.queue_free() + if not attack_bomb_scene: + return + var bomb = attack_bomb_scene.instantiate() + get_parent().add_child(bomb) + bomb.global_position = place_pos + bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse starts in _deferred_ready + if multiplayer.has_multiplayer_peer(): + bomb.set_multiplayer_authority(get_multiplayer_authority()) + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_bomb_dropped", [bomb_name, place_pos]) + print(name, " dropped bomb at ", place_pos, " (fuse lit)") + return print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")") @@ -3484,6 +3538,10 @@ func _perform_attack(): # Round to 1 decimal place final_damage = round(final_damage * 10.0) / 10.0 + # Track what we spawned so we only sync when we actually shot a projectile + var spawned_projectile_type: String = "" + var sync_arrow_name: String = "" # Stable name for arrow sync (multiplayer) + # Handle bow attacks - require arrows in off-hand if is_bow: # Check for arrows in off-hand @@ -3493,12 +3551,19 @@ func _perform_attack(): if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION: arrows = offhand_item + # Reset empty-bow counter when we have arrows + if arrows and arrows.quantity > 0: + empty_bow_shot_attempts = 0 + # Only spawn arrow if we have arrows if arrows and arrows.quantity > 0: if attack_arrow_scene: - + spawned_projectile_type = "arrow" $SfxBowShoot.play() var arrow_projectile = attack_arrow_scene.instantiate() + sync_arrow_name = "arrow_%d_%d" % [multiplayer.get_unique_id(), _arrow_spawn_seq] + _arrow_spawn_seq += 1 + arrow_projectile.name = sync_arrow_name get_parent().add_child(arrow_projectile) # Spawn arrow 4 pixels in the direction player is looking var arrow_spawn_pos = global_position + (attack_direction * 4.0) @@ -3521,14 +3586,20 @@ func _perform_attack(): character_stats.character_changed.emit(character_stats) print(name, " shot arrow! Arrows remaining: ", remaining) else: - # Play bow animation but no projectile - # Play sound for trying to shoot without arrows + # Play bow animation but no projectile - DO NOT sync attack (no arrow spawned) if has_node("SfxBowWithoutArrow"): $SfxBowWithoutArrow.play() print(name, " tried to shoot but has no arrows!") + + # Track empty bow attempts; after 3, unequip bow and equip another weapon + empty_bow_shot_attempts += 1 + if empty_bow_shot_attempts >= 3: + empty_bow_shot_attempts = 0 + _unequip_bow_and_equip_other_weapon() elif is_staff: # Spawn staff projectile for staff weapons if staff_projectile_scene and equipped_weapon: + spawned_projectile_type = "staff" var projectile = staff_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_direction, self, final_damage, equipped_weapon) @@ -3542,6 +3613,7 @@ func _perform_attack(): else: # Spawn sword projectile for non-bow/staff weapons if sword_projectile_scene: + spawned_projectile_type = "sword" var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_direction, self, final_damage) @@ -3553,9 +3625,10 @@ func _perform_attack(): projectile.global_position = global_position + spawn_offset print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") - # Sync attack over network - if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage]) + # Sync attack over network only when we actually spawned a projectile + if spawned_projectile_type != "" and multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + var arrow_name_arg = sync_arrow_name if spawned_projectile_type == "arrow" else "" + _rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage, spawned_projectile_type, arrow_name_arg]) # Reset attack cooldown (instant if cooldown is 0) if attack_cooldown > 0: @@ -3564,6 +3637,64 @@ func _perform_attack(): can_attack = true is_attacking = false +func _unequip_bow_and_equip_other_weapon(): + # After 3 empty bow shots: unequip bow, equip another mainhand weapon if any, show messages + if not is_multiplayer_authority() or not character_stats: + return + + var mainhand = character_stats.equipment.get("mainhand", null) + if not mainhand or mainhand.weapon_type != Item.WeaponType.BOW: + return + + # Unequip bow (moves it to inventory) + character_stats.unequip_item(mainhand, true) + + # Show "Bow unequipped" message + _show_equipment_message("Bow unequipped.") + + # Find first mainhand weapon in inventory that is not a bow + var other_weapon = null + for i in range(character_stats.inventory.size()): + var it = character_stats.inventory[i] + if not it: + continue + if it.equipment_type != Item.EquipmentType.MAINHAND: + continue + if it.weapon_type == Item.WeaponType.BOW: + continue + other_weapon = it + break + + if other_weapon: + character_stats.equip_item(other_weapon, -1) + _show_equipment_message("%s equipped." % other_weapon.item_name) + + # Sync equipment/inventory to other clients (same as _on_character_changed) + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + var equipment_data = {} + for slot_name in character_stats.equipment.keys(): + var item = character_stats.equipment[slot_name] + if item: + equipment_data[slot_name] = item.save() + else: + equipment_data[slot_name] = null + _rpc_to_ready_peers("_sync_equipment", [equipment_data]) + var inventory_data = [] + for item in character_stats.inventory: + if item: + inventory_data.append(item.save()) + _rpc_to_ready_peers("_sync_inventory", [inventory_data]) + + _apply_appearance_to_sprites() + var other_name = other_weapon.item_name if other_weapon else "none" + print(name, " unequipped bow (no arrows x3); other weapon: ", other_name) + +func _show_equipment_message(text: String): + # Local-only so the player who unequipped always sees it (host or client) + var chat_ui = get_tree().get_first_node_in_group("chat_ui") + if chat_ui and chat_ui.has_method("add_local_message"): + chat_ui.add_local_message("System", text) + func _create_bomb_object(): # Dwarf: Create interactable bomb object that can be lifted/thrown if not is_multiplayer_authority(): @@ -3629,9 +3760,13 @@ func _create_bomb_object(): 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 + push_axis = _snap_to_8_directions(facing_direction_vector) if facing_direction_vector.length() > 0.1 else Vector2.DOWN + if "is_being_held" in bomb_obj: + bomb_obj.is_being_held = true + if "held_by_player" in bomb_obj: + bomb_obj.held_by_player = self # Freeze the bomb if "is_frozen" in bomb_obj: @@ -3644,19 +3779,14 @@ func _create_bomb_object(): # Play lift animation _set_animation("LIFT") - # Sync to network + # Sync bomb spawn to other clients so they see it when lifted, then sync grab state 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) + var obj_name = bomb_obj.name if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree(): + _rpc_to_ready_peers("_sync_create_bomb_object", [obj_name, bomb_obj.global_position]) _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 @@ -3787,10 +3917,9 @@ func _can_cast_spell_at(target_position: Vector2) -> bool: if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: return false - # Check if it's a floor tile (grid value 1) or corridor (grid value 3) - # Allow casting on both floor and corridor tiles + # Check if it's floor (1), door (2), or corridor (3) - same as walkable var grid_value = grid[tile_x][tile_y] - if grid_value != 1 and grid_value != 3: + if grid_value != 1 and grid_value != 2 and grid_value != 3: return false # Check if there's a wall between player and target using raycast @@ -3811,7 +3940,9 @@ func _can_cast_spell_at(target_position: Vector2) -> bool: return true func _start_spell_charge_particles(): - # Create particle system for spell charging + # Create particle system for spell charging (only if enabled) + if not use_spell_charge_particles: + return if spell_charge_particles: _stop_spell_charge_particles() @@ -3821,8 +3952,8 @@ func _start_spell_charge_particles(): spell_charge_particle_timer = 0.0 func _update_spell_charge_particles(charge_progress: float): - # Update particle system based on charge progress - if not spell_charge_particles or not is_instance_valid(spell_charge_particles): + # Update particle system based on charge progress (skip if disabled) + if not use_spell_charge_particles or not spell_charge_particles or not is_instance_valid(spell_charge_particles): return var star_texture = load("res://assets/gfx/fx/magic/red_star.png") @@ -3892,6 +4023,26 @@ func _stop_spell_charge_particles(): spell_charge_particles.queue_free() spell_charge_particles = null +func _start_spell_charge_incantation(): + # Play fire_charging on AnimationIncantation when starting spell charge + spell_incantation_fire_ready_shown = false + if has_node("AnimationIncantation"): + $AnimationIncantation.play("fire_charging") + +func _update_spell_charge_incantation(charge_progress: float): + # Switch to fire_ready when fully charged (fire_charging already playing from start) + if not has_node("AnimationIncantation"): + return + if charge_progress >= 1.0 and not spell_incantation_fire_ready_shown: + $AnimationIncantation.play("fire_ready") + spell_incantation_fire_ready_shown = true + +func _stop_spell_charge_incantation(): + # Reset incantation when spell charge ends + spell_incantation_fire_ready_shown = false + if has_node("AnimationIncantation"): + $AnimationIncantation.play("idle") + func _apply_spell_charge_tint(): # Apply pulsing tint to all sprite layers when fully charged using shader parameters # Pulse between original tint and spell charge tint @@ -4129,6 +4280,7 @@ func _sync_spell_charge_start(): is_charging_spell = true spell_charge_start_time = Time.get_ticks_msec() / 1000.0 _start_spell_charge_particles() + _start_spell_charge_incantation() print(name, " (synced) started charging spell") @rpc("any_peer", "reliable") @@ -4138,6 +4290,7 @@ func _sync_spell_charge_end(): is_charging_spell = false spell_incantation_played = false _stop_spell_charge_particles() + _stop_spell_charge_incantation() _clear_spell_charge_tint() # Return to IDLE animation @@ -4486,76 +4639,51 @@ func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bo shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 @rpc("any_peer", "reliable") -func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float = 1.0): - # Sync attack to other clients - # Check if node still exists and is valid before processing +func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float = 1.0, projectile_type: String = "sword", arrow_name: String = ""): + # Sync attack to other clients. Use projectile_type from authority (what was actually spawned), + # not equipment - fixes no-arrows and post-unequip bow desync. if not is_inside_tree() or not is_instance_valid(self): return if not is_multiplayer_authority(): current_direction = direction as Direction - # Determine weapon type for animation and projectile - var equipped_weapon = null - var is_staff = false - var is_bow = false - if character_stats and character_stats.equipment.has("mainhand"): - equipped_weapon = character_stats.equipment["mainhand"] - if equipped_weapon: - if equipped_weapon.weapon_type == Item.WeaponType.STAFF: - is_staff = true - elif equipped_weapon.weapon_type == Item.WeaponType.BOW: - is_bow = true - - # Set appropriate animation - if is_staff: - _set_animation("STAFF") - elif is_bow: - _set_animation("BOW") - else: - _set_animation("SWORD") + # Set animation from projectile_type (authority knows what they shot) + match projectile_type: + "staff": + _set_animation("STAFF") + "arrow": + _set_animation("BOW") + _: + _set_animation("SWORD") # Delay before spawning projectile await get_tree().create_timer(0.15).timeout - # Check again after delay - node might have been destroyed if not is_inside_tree() or not is_instance_valid(self): return - # Spawn appropriate projectile on client - if is_staff and staff_projectile_scene and equipped_weapon: + # Spawn only what authority actually spawned (ignore equipment) + if projectile_type == "staff" and staff_projectile_scene: + var equipped_weapon = character_stats.equipment.get("mainhand", null) if character_stats else null var projectile = staff_projectile_scene.instantiate() get_parent().add_child(projectile) - projectile.setup(attack_dir, self, 20.0, equipped_weapon) - # Spawn projectile a bit in front of the player - var spawn_offset = attack_dir * 10.0 # 10 pixels in front + projectile.setup(attack_dir, self, 20.0, equipped_weapon if equipped_weapon else null) + var spawn_offset = attack_dir * 10.0 projectile.global_position = global_position + spawn_offset print(name, " performed synced staff attack!") - elif is_bow: - # For bow attacks, check if we have arrows (same logic as host) - var arrows = null - 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.AMMUNITION: - arrows = offhand_item - - # Only spawn arrow if we have arrows (matches host behavior) - if arrows and arrows.quantity > 0: - if attack_arrow_scene: - var arrow_projectile = attack_arrow_scene.instantiate() - get_parent().add_child(arrow_projectile) - # Use charge percentage from sync (matches local player's arrow) - arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage) - print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)") - else: - # No arrows - just play animation, no projectile (matches host behavior) - print(name, " performed synced bow attack without arrows (no projectile)") - elif sword_projectile_scene: + elif projectile_type == "arrow" and attack_arrow_scene: + var arrow_projectile = attack_arrow_scene.instantiate() + if arrow_name != "": + arrow_projectile.name = arrow_name + get_parent().add_child(arrow_projectile) + arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage) + print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)") + elif (projectile_type == "sword" or projectile_type == "") and sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_dir, self) - # Spawn projectile a bit in front of the player - var spawn_offset = attack_dir * 10.0 # 10 pixels in front + var spawn_offset = attack_dir * 10.0 projectile.global_position = global_position + spawn_offset print(name, " performed synced attack!") @@ -4575,9 +4703,54 @@ func _sync_bow_charge_end(): _clear_bow_charge_tint() print(name, " (synced) ended charging bow") +@rpc("any_peer", "reliable") +func _sync_create_bomb_object(bomb_name: String, spawn_pos: Vector2): + # Sync Dwarf's lifted bomb spawn to other clients so they see it when held + if is_multiplayer_authority(): + return + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world: + return + var entities_node = game_world.get_node_or_null("Entities") + if not entities_node: + return + if entities_node.get_node_or_null(bomb_name): + return # Already exists (e.g. duplicate RPC) + var interactable_scene = load("res://scenes/interactable_object.tscn") as PackedScene + if not interactable_scene: + return + var bomb_obj = interactable_scene.instantiate() + bomb_obj.name = bomb_name + bomb_obj.global_position = spawn_pos + if multiplayer.has_multiplayer_peer(): + bomb_obj.set_multiplayer_authority(get_multiplayer_authority()) + entities_node.add_child(bomb_obj) + bomb_obj.setup_bomb() + print(name, " (synced) created bomb object ", bomb_name, " at ", spawn_pos) + +@rpc("any_peer", "reliable") +func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2): + # Sync Dwarf drop: free lifted bomb on clients, spawn attack_bomb with fuse lit + if not is_multiplayer_authority(): + var game_world = get_tree().get_first_node_in_group("game_world") + var entities_node = game_world.get_node_or_null("Entities") if game_world else null + if entities_node and bomb_name.begins_with("BombObject_"): + var lifted = entities_node.get_node_or_null(bomb_name) + if lifted and is_instance_valid(lifted): + lifted.queue_free() + if not attack_bomb_scene: + return + var bomb = attack_bomb_scene.instantiate() + get_parent().add_child(bomb) + bomb.global_position = place_pos + bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit + if multiplayer.has_multiplayer_peer(): + bomb.set_multiplayer_authority(get_multiplayer_authority()) + print(name, " (synced) dropped bomb at ", place_pos) + @rpc("any_peer", "reliable") func _sync_place_bomb(target_pos: Vector2): - # Sync bomb placement to other clients + # Sync bomb placement to other clients (Human/Elf) if not is_multiplayer_authority(): if not attack_bomb_scene: return @@ -4593,24 +4766,23 @@ func _sync_place_bomb(target_pos: Vector2): 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 +func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2): + # Sync bomb throw to other clients; free lifted bomb (BombObject_*) if it exists if not is_multiplayer_authority(): + var game_world = get_tree().get_first_node_in_group("game_world") + var entities_node = game_world.get_node_or_null("Entities") if game_world else null + if entities_node and bomb_name.begins_with("BombObject_"): + var lifted = entities_node.get_node_or_null(bomb_name) + if lifted and is_instance_valid(lifted): + lifted.queue_free() 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") @@ -5153,6 +5325,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool is_charging_spell = false spell_incantation_played = false _stop_spell_charge_particles() + _stop_spell_charge_incantation() _clear_spell_charge_tint() # Return to IDLE animation @@ -5223,6 +5396,9 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool # Play damage animation _set_animation("DAMAGE") + # Lock facing direction briefly so player can't change it while taking damage + damage_direction_lock_timer = damage_direction_lock_duration + # Only apply knockback if not burn damage if not is_burn_damage: # Calculate direction FROM attacker TO victim @@ -5280,6 +5456,7 @@ func _die(): is_dead = true # Ensure flag is set velocity = Vector2.ZERO is_knocked_back = false + damage_direction_lock_timer = 0.0 # CRITICAL: Release any held object/player BEFORE dying to restore their collision layers if held_object: @@ -5417,6 +5594,7 @@ func _respawn(): velocity = Vector2.ZERO is_knocked_back = false is_airborne = false + damage_direction_lock_timer = 0.0 position_z = 0.0 velocity_z = 0.0