diff --git a/src/assets/audio/sfx/traps/activate.mp3 b/src/assets/audio/sfx/traps/activate.mp3 new file mode 100644 index 0000000..3170fc0 Binary files /dev/null and b/src/assets/audio/sfx/traps/activate.mp3 differ diff --git a/src/assets/audio/sfx/traps/activate.mp3.import b/src/assets/audio/sfx/traps/activate.mp3.import new file mode 100644 index 0000000..2ad1a95 --- /dev/null +++ b/src/assets/audio/sfx/traps/activate.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://raqmpvp1vj04" +path="res://.godot/imported/activate.mp3-9f00271ed50842203601cea6b7140d55.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/traps/activate.mp3" +dest_files=["res://.godot/imported/activate.mp3-9f00271ed50842203601cea6b7140d55.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/project.godot b/src/project.godot index deaf0da..57b0a95 100644 --- a/src/project.godot +++ b/src/project.godot @@ -67,25 +67,35 @@ grab={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) ] } throw={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) ] } attack={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) ] } inventory={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":3,"canceled":false,"pressed":false,"double_click":false,"script":null) ] } +[layer_names] + +2d_physics/layer_1="Player" +2d_physics/layer_7="Walls" +2d_physics/layer_13="traps" + [physics] 3d/physics_engine="Jolt Physics" diff --git a/src/scenes/attack_arrow.tscn b/src/scenes/attack_arrow.tscn index f6420c1..2b8529e 100644 --- a/src/scenes/attack_arrow.tscn +++ b/src/scenes/attack_arrow.tscn @@ -5,6 +5,7 @@ [ext_resource type="AudioStream" uid="uid://hmci4kgvbqib" path="res://assets/audio/sfx/weapons/bow/arrow_fire_swosh.wav" id="3_o8cb2"] [ext_resource type="AudioStream" uid="uid://b140nlsak4ub7" path="res://assets/audio/sfx/weapons/bow/arrow-hit-brick-wall-01.mp3" id="4_8l43l"] [ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="4_ol4b0"] +[ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="6_e0wjs"] [sub_resource type="Gradient" id="Gradient_yp18a"] colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) @@ -56,21 +57,44 @@ debug_color = Color(0.7, 0, 0.195726, 0.42) [node name="SfxArrowFire" type="AudioStreamPlayer2D" parent="." unique_id=1413495156] stream = ExtResource("3_o8cb2") pitch_scale = 1.61 +attenuation = 7.464256 max_polyphony = 4 +panning_strength = 1.01 +bus = &"Sfx" [node name="SfxImpactWall" type="AudioStreamPlayer2D" parent="." unique_id=1132726967] stream = ExtResource("4_8l43l") volume_db = -4.0 pitch_scale = 1.29 -attenuation = 3.4822 +attenuation = 3.9999971 max_polyphony = 4 panning_strength = 1.3 +bus = &"Sfx" [node name="SfxImpactSound" type="AudioStreamPlayer2D" parent="." unique_id=463883211] stream = ExtResource("4_ol4b0") volume_db = -4.685 pitch_scale = 1.47 +attenuation = 8.574182 max_polyphony = 4 +panning_strength = 1.03 +bus = &"Sfx" + +[node name="SfxPickup" type="AudioStreamPlayer2D" parent="." unique_id=916101887] +stream = ExtResource("6_e0wjs") +volume_db = -2.204 +pitch_scale = 1.79 +attenuation = 6.498014 +panning_strength = 1.06 +bus = &"Sfx" + +[node name="SfxLandsOnGround" type="AudioStreamPlayer2D" parent="." unique_id=1827010383] +stream = ExtResource("6_e0wjs") +volume_db = -2.645 +pitch_scale = 1.65 +attenuation = 5.6568513 +panning_strength = 1.08 +bus = &"Sfx" [connection signal="area_entered" from="ArrowArea" to="." method="_on_arrow_area_area_entered"] [connection signal="body_entered" from="ArrowArea" to="." method="_on_arrow_area_body_entered"] diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 2c7c1ca..38c06d6 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -31,6 +31,10 @@ [ext_resource type="AudioStream" uid="uid://w6yon88kjfml" path="res://assets/audio/sfx/nickes/lift_sfx.ogg" id="28_pf23h"] [ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="30_gl8cc"] [ext_resource type="AudioStream" uid="uid://mbtpqlb5n3gd" path="res://assets/audio/sfx/weapons/bow/bow_no_arrow.mp3" id="31_487ah"] +[ext_resource type="AudioStream" uid="uid://fm6hrpckfknc" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_03.wav" id="32_bj30b"] +[ext_resource type="AudioStream" uid="uid://be3uspidyqm3x" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_04.wav" id="33_jc3p3"] +[ext_resource type="AudioStream" uid="uid://dvttykynr671m" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_05.wav" id="34_hax0n"] +[ext_resource type="AudioStream" uid="uid://sejnuklu653m" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_06.wav" id="35_t4otl"] [sub_resource type="Gradient" id="Gradient_wqfne"] colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) @@ -280,6 +284,14 @@ random_pitch = 1.0630184 streams_count = 1 stream_0/stream = ExtResource("31_487ah") +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_j2b1d"] +random_pitch = 1.1036249 +streams_count = 4 +stream_0/stream = ExtResource("32_bj30b") +stream_1/stream = ExtResource("33_jc3p3") +stream_2/stream = ExtResource("34_hax0n") +stream_3/stream = ExtResource("35_t4otl") + [node name="Player" type="CharacterBody2D" unique_id=937429705] collision_mask = 67 motion_mode = 1 diff --git a/src/scenes/trap.tscn b/src/scenes/trap.tscn new file mode 100644 index 0000000..f7d4a04 --- /dev/null +++ b/src/scenes/trap.tscn @@ -0,0 +1,133 @@ +[gd_scene format=3 uid="uid://bcgdkojealqoo"] + +[ext_resource type="Script" uid="uid://bkp8t4mvdhtqq" path="res://scripts/trap.gd" id="1_62q8x"] +[ext_resource type="Texture2D" uid="uid://b6eeio3gm7d4u" path="res://assets/gfx/traps/Floor_Lance.png" id="2_aucmg"] +[ext_resource type="AudioStream" uid="uid://raqmpvp1vj04" path="res://assets/audio/sfx/traps/activate.mp3" id="3_tk2q1"] +[ext_resource type="AudioStream" uid="uid://dxy2phfh0ojot" path="res://assets/audio/sfx/player/dodge/Dodge.mp3" id="4_1sb0t"] + +[sub_resource type="Animation" id="Animation_tk2q1"] +length = 0.001 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Sprite2D: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="Animation" id="Animation_1sb0t"] +resource_name = "activate" +length = 1.4333333 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Sprite2D:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.033333335, 0.06666667, 0.1), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [0, 1, 2, 3] +} + +[sub_resource type="Animation" id="Animation_62q8x"] +resource_name = "idle" +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Sprite2D: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="Animation" id="Animation_aucmg"] +resource_name = "reset" +length = 0.2 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Sprite2D:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.033333335, 0.06666667, 0.1), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [3, 2, 1, 0] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_7s8xa"] +_data = { +&"RESET": SubResource("Animation_tk2q1"), +&"activate": SubResource("Animation_1sb0t"), +&"idle": SubResource("Animation_62q8x"), +&"reset": SubResource("Animation_aucmg") +} + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_62q8x"] +size = Vector2(16, 16) + +[sub_resource type="CircleShape2D" id="CircleShape2D_aucmg"] +radius = 17.117243 + +[sub_resource type="CircleShape2D" id="CircleShape2D_62q8x"] +radius = 99.0202 + +[node name="Trap" type="Node2D" unique_id=131165873] +script = ExtResource("1_62q8x") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1625881554] +texture = ExtResource("2_aucmg") +hframes = 4 + +[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1394959342] +libraries/ = SubResource("AnimationLibrary_7s8xa") + +[node name="ActivationArea" type="Area2D" parent="." unique_id=694690572] +collision_layer = 4096 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="ActivationArea" unique_id=1574459451] +shape = SubResource("RectangleShape2D_62q8x") + +[node name="DisarmArea" type="Area2D" parent="." unique_id=687041294] +collision_layer = 4096 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DisarmArea" unique_id=1192467236] +shape = SubResource("CircleShape2D_aucmg") +debug_color = Color(0.70196074, 0.614594, 0.08530171, 0.41960785) + +[node name="DetectionArea" type="Area2D" parent="." unique_id=1872122085] +collision_layer = 0 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DetectionArea" unique_id=1024222789] +shape = SubResource("CircleShape2D_62q8x") +debug_color = Color(0.53960574, 0.04878081, 0.70196074, 0.41960785) + +[node name="SfxActivate" type="AudioStreamPlayer2D" parent="." unique_id=312292112] +stream = ExtResource("3_tk2q1") +volume_db = -1.559 +attenuation = 4.438279 +panning_strength = 1.03 +bus = &"Sfx" + +[node name="SfxAvoid" type="AudioStreamPlayer2D" parent="." unique_id=322639992] +stream = ExtResource("4_1sb0t") +pitch_scale = 1.35 +attenuation = 1.9318731 +bus = &"Sfx" + +[node name="SfxDisarming" type="AudioStreamPlayer2D" parent="." unique_id=920322213] +bus = &"Sfx" + +[connection signal="body_shape_entered" from="ActivationArea" to="." method="_on_activation_area_body_shape_entered"] diff --git a/src/scripts/attack_arrow.gd b/src/scripts/attack_arrow.gd index 47dadbf..967c1d2 100644 --- a/src/scripts/attack_arrow.gd +++ b/src/scripts/attack_arrow.gd @@ -2,22 +2,37 @@ extends CharacterBody2D var speed = 300 var direction = Vector2.ZERO -var stick_duration = 3.0 # How long the arrow stays stuck to walls +var stick_duration = 3.0 # How long the arrow stays stuck to enemies/players +var wall_stick_duration = 30.0 # Much longer duration for wall-stuck arrows (30 seconds) var is_stuck = false +var is_collected = false var stick_timer = 0.0 +var stuck_to_wall = false # Track if stuck to wall (vs enemy/player) +var can_be_collected = false # True after delay on wall +var shooter_can_collect = false # Shooter can collect after 0.2 seconds +var shooter_collection_delay = 0.2 # Fast pickup for shooter +var others_collection_delay = 5.0 # Other players wait 5 seconds + +# Flight duration based on charge +var flight_timer = 0.0 +var max_flight_duration = 6.0 # How long arrow flies before landing (set by charge) +var can_deal_damage = true # False after arrow "lands" in flight var initiated_by: Node2D = null var player_owner: Node = null # Like sword_projectile var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) +# Collection area for wall-stuck arrows +var collection_area: Area2D = null + @onready var arrow_area = $ArrowArea # Assuming you have an Area2D node named ArrowArea @onready var shadow = $Shadow # Assuming you have a Shadow node under the CharacterBody2D # Called when the node enters the scene tree for the first time. func _ready() -> void: arrow_area.set_deferred("monitoring", true) - # Connect area signals - if arrow_area: + # Connect area signals (only if not already connected) + if arrow_area and not arrow_area.body_entered.is_connected(_on_arrow_area_body_entered): arrow_area.body_entered.connect(_on_arrow_area_body_entered) $SfxArrowFire.play() call_deferred("_initialize_arrow") @@ -53,22 +68,62 @@ func _initialize_arrow() -> void: # Apply the scaling to the shadow shadow.rotation = -(angle - PI / 2) -func shoot(shoot_direction: Vector2, start_pos: Vector2, owner_player: Node = null) -> void: +func shoot(shoot_direction: Vector2, start_pos: Vector2, owner_player: Node = null, charge_percentage: float = 1.0) -> void: direction = shoot_direction.normalized() global_position = start_pos player_owner = owner_player initiated_by = owner_player + + # Apply charge percentage to arrow speed and flight duration + # Speed: min 120, max 320, scales with charge % (0.5 to 1.0) + var min_speed = 120.0 + var max_speed = 320.0 + speed = lerp(min_speed, max_speed, (charge_percentage - 0.5) * 2.0) # Map 0.5-1.0 to 0.0-1.0 range + + # Flight duration: 50% charge = 0.5s, 100% charge = 2.5s + max_flight_duration = lerp(0.5, 2.5, (charge_percentage - 0.5) * 2.0) + + print("Arrow shot at ", charge_percentage * 100, "% charge (speed: ", speed, ", flight duration: ", max_flight_duration, "s)") # Called every frame. 'delta' is the e lapsed time since the previous frame. func _process(delta: float) -> void: + if not is_stuck: + # Track flight time and "land" arrow after max_flight_duration + flight_timer += delta + if flight_timer >= max_flight_duration: + # Arrow has flown for max duration - "land" it (stop and stick to ground) + can_deal_damage = false + $SfxLandsOnGround.play() + _stick_to_wall() # Land on ground + print("Arrow landed after flying for ", flight_timer, " seconds") + return # Exit early to prevent further movement this frame + + # Continue flying + velocity = direction * speed + if is_stuck: # Handle fade out here if it's stuck stick_timer += delta - if stick_timer >= stick_duration: + + # Enable collection for wall-stuck arrows with different delays + if stuck_to_wall: + # Shooter can collect after 0.2 seconds + if stick_timer >= shooter_collection_delay and not shooter_can_collect: + shooter_can_collect = true + _enable_collection_area() + # Everyone else can collect after 5 seconds + if stick_timer >= others_collection_delay and not can_be_collected: + can_be_collected = true + + # Use appropriate duration based on what it's stuck to + var duration = wall_stick_duration if stuck_to_wall else stick_duration + + if stick_timer >= duration: # Start fading out after it sticks - modulate.a = max(0, 1 - (stick_timer - stick_duration) / 1.0) # Fade out over 1 second - if stick_timer >= stick_duration + 1.0: # Extra second for fade out + modulate.a = max(0, 1 - (stick_timer - duration) / 1.0) # Fade out over 1 second + if stick_timer >= duration + 1.0: # Extra second for fade out queue_free() # Remove the arrow after fade out + move_and_slide() func _physics_process(_delta: float) -> void: @@ -86,6 +141,14 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: if is_stuck: return + # Arrow has landed (flew too long) - can't deal damage anymore + if not can_deal_damage: + # Check if it's a wall/terrain (not player/enemy) - stick to it + if not body.is_in_group("player") and not body.is_in_group("enemy"): + $SfxImpactWall.play() + _stick_to_wall() + return + # Don't hit the owner if body == player_owner or body == initiated_by: return @@ -94,30 +157,36 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: if body in hit_targets: return - # CRITICAL: Only the projectile owner (authority) should deal damage - if player_owner and not player_owner.is_multiplayer_authority(): - return # Only the authority (creator) of the projectile can deal damage - - # Add to hit_targets IMMEDIATELY to prevent multiple hits - hit_targets[body] = true - # Deal damage to players if body.is_in_group("player") and body.has_method("rpc_take_damage"): + # Add to hit_targets to prevent multiple hits on this target + hit_targets[body] = true + play_impact() - 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(20.0, attacker_pos) # TODO: Get actual damage from player + + # CRITICAL: Only the projectile owner (authority) should deal damage to players + if player_owner and player_owner.is_multiplayer_authority(): + 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(20.0, attacker_pos) # TODO: Get actual damage from player + else: + body.rpc_take_damage.rpc_id(player_peer_id, 20.0, attacker_pos) else: - body.rpc_take_damage.rpc_id(player_peer_id, 20.0, attacker_pos) - else: - body.rpc_take_damage.rpc(20.0, attacker_pos) + body.rpc_take_damage.rpc(20.0, attacker_pos) + + # Stick to target on ALL clients (both authority and non-authority) _stick_to_target(body) return # Deal damage to enemies if body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + # CRITICAL: Only the authority should process enemy collisions + # This ensures hit/miss/dodge calculations happen once and are consistent + if player_owner and not player_owner.is_multiplayer_authority(): + return # Non-authority ignores enemy collisions + var attacker_pos = player_owner.global_position if player_owner else global_position var damage = 20.0 # TODO: Get actual damage from player if player_owner and player_owner.character_stats: @@ -131,12 +200,42 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: var is_miss = hit_roll >= hit_chance if is_miss: + # MISS - arrow passes through enemy and continues flying! if body.has_method("_show_damage_number"): body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true - _stick_to_target(body) + # Add to hit_targets so we don't check this enemy again + hit_targets[body] = true + # Sync miss to all clients - arrow continues flying + if is_inside_tree(): + _sync_arrow_miss.rpc(body.get_path()) + # Don't stick to target - let arrow continue flying return + # Check enemy dodge chance (based on enemy's DEX stat) + var dodge_roll = randf() + var dodge_chance = 0.0 + if body.character_stats: + dodge_chance = body.character_stats.dodge_chance + var is_dodge = dodge_roll < dodge_chance + + if is_dodge: + # DODGE - arrow passes through enemy and continues flying! + if body.has_method("_show_damage_number"): + body._show_damage_number(0.0, attacker_pos, false, false, true) # is_dodged = true + # Add to hit_targets so we don't check this enemy again + hit_targets[body] = true + # Sync dodge to all clients - arrow continues flying + if is_inside_tree(): + _sync_arrow_dodge.rpc(body.get_path()) + # Don't stick to target - let arrow continue flying + print(body.name, " DODGED arrow! Arrow continues flying...") + return + + # HIT - add to hit_targets and stick to enemy + hit_targets[body] = true play_impact() + + # Deal damage 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(): @@ -145,6 +244,11 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, false) else: body.rpc_take_damage.rpc(damage, attacker_pos, false) + + # Sync hit to all clients - arrow sticks + if is_inside_tree(): + _sync_arrow_hit.rpc(body.get_path()) + _stick_to_target(body) return @@ -175,5 +279,162 @@ func _stick_to_wall(): # Stop the arrow velocity = Vector2.ZERO is_stuck = true + stuck_to_wall = true 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") + +@rpc("any_peer", "call_local", "reliable") +func _sync_arrow_hit(target_path: NodePath): + # Authority determined arrow HIT enemy - stick to it on all clients + var target = get_node_or_null(target_path) + if not target: + print("WARNING: Arrow hit target not found at path: ", target_path) + return + + if target not in hit_targets: + hit_targets[target] = true + play_impact() + _stick_to_target(target) + print("Arrow synced as HIT to: ", target.name) + +@rpc("any_peer", "call_local", "reliable") +func _sync_arrow_miss(target_path: NodePath): + # Authority determined arrow MISSED enemy - continues flying on all clients + var target = get_node_or_null(target_path) + if target and target not in hit_targets: + hit_targets[target] = true + print("Arrow synced as MISS - continuing through: ", target.name if target else "unknown") + +@rpc("any_peer", "call_local", "reliable") +func _sync_arrow_dodge(target_path: NodePath): + # Authority determined enemy DODGED arrow - continues flying on all clients + var target = get_node_or_null(target_path) + if target and target not in hit_targets: + hit_targets[target] = true + print("Arrow synced as DODGE - continuing through: ", target.name if target else "unknown") + +func _enable_collection_area(): + # Create an Area2D for collecting the arrow + if collection_area: + return # Already created + + collection_area = Area2D.new() + collection_area.name = "CollectionArea" + collection_area.collision_layer = 0 + collection_area.collision_mask = 1 # Detect players (layer 1) + + var collision_shape = CollisionShape2D.new() + var circle = CircleShape2D.new() + circle.radius = 16.0 # Collection radius + collision_shape.shape = circle + collection_area.add_child(collision_shape) + + add_child(collection_area) + collection_area.body_entered.connect(_on_collection_area_body_entered) + +func _on_collection_area_body_entered(body: Node2D): + if not is_instance_valid(self): + return + + # Check if it's a player + if not body.is_in_group("player"): + return + if is_collected: + return + + # Only local players can collect + if not body.is_local_player: + return + + # Check if this player is allowed to collect + var is_shooter = (body == player_owner or body == initiated_by) + if is_shooter: + # Shooter can collect after short delay + if not shooter_can_collect: + return + else: + # Other players need to wait longer + if not can_be_collected: + return + + # Create arrow item and add to player + if not body.character_stats or not is_instance_valid(body.character_stats): + print("ERROR: body.character_stats is invalid when trying to collect arrow") + return + + # 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) + await $SfxPickup.finished + queue_free() + return + + # Check if offhand is empty and player has a bow equipped + var mainhand_item = body.character_stats.equipment.get("mainhand", null) + var has_bow = mainhand_item and is_instance_valid(mainhand_item) and mainhand_item.weapon_type == Item.WeaponType.BOW + + if not offhand_item and has_bow: + # Re-equip arrow to offhand if player has a bow and offhand is empty + 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!") + await $SfxPickup.finished + queue_free() + return + + # Add to inventory (will stack if arrows already in inventory) + var inventory_arrow = ItemDatabase.create_item("arrow") + if not inventory_arrow or not is_instance_valid(inventory_arrow): + print("ERROR: Failed to create arrow item for inventory") + 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") + 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 diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index 499687c..a7e3487 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -73,7 +73,8 @@ var equipment:Dictionary = { "end": 10, "wis": 10, "cha": 10, - "lck": 10 + "lck": 10, + "per": 10 # Perception - affects trap detection } @export var def: int = 0 @@ -95,16 +96,20 @@ func get_total_weight() -> float: for item in inventory: if item: total += item.weight * item.quantity - # Count equipped items + # Count equipped items (also multiply by quantity for stackable items like arrows) for slot in equipment.values(): if slot: - total += slot.weight + if slot.can_have_multiple_of: + total += slot.weight * slot.quantity + else: + total += slot.weight return total # Calculate carrying capacity based on STR func get_carrying_capacity() -> float: - # Base capacity: 20 + (STR * 5) - return 20.0 + (baseStats.str * 5.0) + # Base capacity: 7 + (STR * 1.75) - reduced to 35% of original + # Example: 10 STR = 24.5, 20 STR = 42 + return 7.0 + (baseStats.str * 1.75) # Check if over-encumbered func is_over_encumbered() -> bool: @@ -245,7 +250,7 @@ func level_up() -> void: var num_stats_to_increase = randi_range(2, 3) # All available stats (excluding cha for now as per user request) - var available_stats = ["str", "dex", "int", "end", "wis", "lck"] + var available_stats = ["str", "dex", "int", "end", "wis", "lck", "per"] # Shuffle and pick random stats var stats_to_increase = [] @@ -550,27 +555,37 @@ func add_item(iItem:Item): func unequip_item(iItem:Item, updateChar:bool = true): if iItem.equipment_type == Item.EquipmentType.NONE: return - self.inventory.push_back(iItem) + + # Check if we can stack with existing item in inventory (for stackable items like arrows) + var stacked = false + if iItem.can_have_multiple_of: + for inv_item in inventory: + if inv_item and inv_item.can_have_multiple_of and inv_item.item_name == iItem.item_name: + # Found matching stackable item in inventory - merge quantities + inv_item.quantity += iItem.quantity + stacked = true + print("Unequipped ", iItem.quantity, " ", iItem.item_name, " and stacked with inventory (new total: ", inv_item.quantity, ")") + break + + # If not stacked, add to inventory normally + if not stacked: + self.inventory.push_back(iItem) + + # Clear equipment slot match iItem.equipment_type: Item.EquipmentType.MAINHAND: equipment["mainhand"] = null - pass Item.EquipmentType.OFFHAND: equipment["offhand"] = null - pass Item.EquipmentType.HEADGEAR: equipment["headgear"] = null - pass Item.EquipmentType.ARMOUR: equipment["armour"] = null - pass Item.EquipmentType.BOOTS: equipment["boots"] = null - pass Item.EquipmentType.ACCESSORY: equipment["accessory"] = null - pass - pass + if updateChar: emit_signal("character_changed", self) pass @@ -587,55 +602,74 @@ func equip_item(iItem:Item, insert_index: int = -1): var old_item = null var item_index = self.inventory.find(iItem) + # Check if we can stack with already equipped item (for stackable items like arrows) + var can_stack = false + var equipped_item = null + match iItem.equipment_type: Item.EquipmentType.MAINHAND: - if equipment["mainhand"] != null: - old_item = equipment["mainhand"] - equipment["mainhand"] = iItem - pass - pass + equipped_item = equipment["mainhand"] Item.EquipmentType.OFFHAND: - if equipment["offhand"] != null: - old_item = equipment["offhand"] - equipment["offhand"] = iItem - pass - pass + equipped_item = equipment["offhand"] Item.EquipmentType.HEADGEAR: - if equipment["headgear"] != null: - old_item = equipment["headgear"] - equipment["headgear"] = iItem - pass - pass - + equipped_item = equipment["headgear"] Item.EquipmentType.ARMOUR: - if equipment["armour"] != null: - old_item = equipment["armour"] - equipment["armour"] = iItem - pass - pass + equipped_item = equipment["armour"] Item.EquipmentType.BOOTS: - if equipment["boots"] != null: - old_item = equipment["boots"] - equipment["boots"] = iItem - pass - pass + equipped_item = equipment["boots"] Item.EquipmentType.ACCESSORY: - if equipment["accessory"] != null: - old_item = equipment["accessory"] - equipment["accessory"] = iItem - pass - pass + equipped_item = equipment["accessory"] - # Remove the item being equipped from inventory first - if item_index >= 0: - self.inventory.remove_at(item_index) + # Check if items can stack (same item name, both stackable) + if equipped_item and iItem.can_have_multiple_of and equipped_item.can_have_multiple_of: + if iItem.item_name == equipped_item.item_name: + can_stack = true - # Add old item back to inventory at the specified position (or end if -1) - if old_item != null: - if insert_index >= 0 and insert_index <= self.inventory.size(): - self.inventory.insert(insert_index, old_item) - else: - self.inventory.push_back(old_item) + if can_stack: + # Stack quantities together + equipped_item.quantity += iItem.quantity + # Remove the item from inventory since we merged it + if item_index >= 0: + self.inventory.remove_at(item_index) + print("Stacked ", iItem.quantity, " ", iItem.item_name, " with equipped (new total: ", equipped_item.quantity, ")") + else: + # Normal equip (swap items) + match iItem.equipment_type: + Item.EquipmentType.MAINHAND: + if equipment["mainhand"] != null: + old_item = equipment["mainhand"] + equipment["mainhand"] = iItem + Item.EquipmentType.OFFHAND: + if equipment["offhand"] != null: + old_item = equipment["offhand"] + equipment["offhand"] = iItem + Item.EquipmentType.HEADGEAR: + if equipment["headgear"] != null: + old_item = equipment["headgear"] + equipment["headgear"] = iItem + Item.EquipmentType.ARMOUR: + if equipment["armour"] != null: + old_item = equipment["armour"] + equipment["armour"] = iItem + Item.EquipmentType.BOOTS: + if equipment["boots"] != null: + old_item = equipment["boots"] + equipment["boots"] = iItem + Item.EquipmentType.ACCESSORY: + if equipment["accessory"] != null: + old_item = equipment["accessory"] + equipment["accessory"] = iItem + + # Remove the item being equipped from inventory first + if item_index >= 0: + self.inventory.remove_at(item_index) + + # Add old item back to inventory at the specified position (or end if -1) + if old_item != null: + if insert_index >= 0 and insert_index <= self.inventory.size(): + self.inventory.insert(insert_index, old_item) + else: + self.inventory.push_back(old_item) emit_signal("character_changed", self) pass diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index 159fcf3..301ea75 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -339,6 +339,9 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - var room_objects = _place_interactable_objects_in_room(room, grid, map_size, all_doors, all_enemies, rng, room_puzzle_data) all_interactable_objects.append_array(room_objects) + # 9.6. Place traps (1-2 per level, excluding start and exit rooms) + var all_traps = _place_traps_in_dungeon(all_rooms, start_room_index, exit_room_index, grid, map_size, rng) + # NOTE: Stairs placement was moved earlier (step 7.5, before torches) to prevent torch overlap # NOTE: Blocking doors placement was moved earlier (step 11, before enemy placement) to identify spawner puzzle rooms @@ -348,6 +351,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - "torches": all_torches, "enemies": all_enemies, "interactable_objects": all_interactable_objects, + "traps": all_traps, "stairs": stairs_data, "blocking_doors": blocking_doors, "grid": grid, @@ -2725,3 +2729,75 @@ func _determine_door_direction_for_puzzle_room(door: Dictionary, puzzle_room: Di return "Left" # Door is left of puzzle room center - door is on left wall else: return "Right" # Door is right of puzzle room center - door is on right wall + +func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_index: int, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Array: + # Place 1-2 traps in the dungeon (not in start or exit rooms) + var traps = [] + var tile_size = 16 + + # Get eligible rooms (not start, not exit) + var eligible_rooms = [] + for i in range(all_rooms.size()): + if i != start_room_index and i != exit_room_index: + eligible_rooms.append(all_rooms[i]) + + # If no eligible rooms, return empty array + if eligible_rooms.size() == 0: + LogManager.log("DungeonGenerator: No eligible rooms for trap placement", LogManager.CATEGORY_DUNGEON) + return traps + + # Decide number of traps (1 or 2) + var num_traps = rng.randi_range(1, 2) + LogManager.log("DungeonGenerator: Placing " + str(num_traps) + " trap(s) in dungeon", LogManager.CATEGORY_DUNGEON) + + # Place traps in random eligible rooms + for trap_idx in range(num_traps): + if eligible_rooms.size() == 0: + break + + # Pick random room + var room_index = rng.randi() % eligible_rooms.size() + var room = eligible_rooms[room_index] + + # Remove room from eligible list to avoid placing multiple traps in same room + eligible_rooms.remove_at(room_index) + + # Try to find a valid floor position in the room (not near walls, doors, or blocking cells) + var attempts = 50 + var trap_placed = false + + while attempts > 0 and not trap_placed: + # Random position in room floor (excluding 2-tile wall border, plus extra safety margin) + var floor_margin = 3 # Extra margin from walls for safety + var local_x = rng.randi_range(floor_margin, room.w - floor_margin - 1) + var local_y = rng.randi_range(floor_margin, room.h - floor_margin - 1) + var world_x = room.x + local_x + var world_y = room.y + local_y + + # Check if position is valid (floor tile, not blocked) + if world_x >= 0 and world_x < map_size.x and world_y >= 0 and world_y < map_size.y: + if grid[world_x][world_y] == 1: # Floor tile + # Check if position is not too close to door (avoid blocking doorways) + var too_close_to_door = false + # Simplified check - just ensure we're not right at door position + # (More complex check could look at actual door positions) + + if not too_close_to_door: + # Valid position - place trap + var trap_world_x = world_x * tile_size + tile_size / 2 + var trap_world_y = world_y * tile_size + tile_size / 2 + + traps.append({ + "position": Vector2(trap_world_x, trap_world_y), + "room": room + }) + + trap_placed = true + LogManager.log("DungeonGenerator: Placed trap at (" + str(trap_world_x) + ", " + str(trap_world_y) + ") in room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) + + attempts -= 1 + + if not trap_placed: + LogManager.log("DungeonGenerator: Failed to place trap in room (" + str(room.x) + ", " + str(room.y) + ") after 50 attempts", LogManager.CATEGORY_DUNGEON) + + return traps diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 8951cb4..ee6f4ff 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -1754,6 +1754,7 @@ func _init_mouse_cursor(): cursor_sprite.hframes = 2 cursor_sprite.vframes = 1 cursor_sprite.frame = 0 # Frame 0 = free movement + cursor_sprite.modulate.a = 0.75 # 75% opacity cursor_layer.add_child(cursor_sprite) # Create grid-locked cursor sprite (frame 1) @@ -1763,7 +1764,7 @@ func _init_mouse_cursor(): grid_cursor_sprite.hframes = 2 grid_cursor_sprite.vframes = 1 grid_cursor_sprite.frame = 1 # Frame 1 = grid-locked - grid_cursor_sprite.modulate.a = 0.5 # 50% opacity + grid_cursor_sprite.modulate.a = 0.3 # 30% opacity cursor_layer.add_child(grid_cursor_sprite) # Hide system cursor @@ -1826,20 +1827,30 @@ func _update_mouse_cursor(delta: float): var base_color = Color(1.0, 1.0, 1.0) var pulse_color = Color(1.5, 1.2, 1.0) grid_cursor_sprite.modulate = base_color.lerp(pulse_color, pulse_value * 0.5) # 50% of the way to pulse color - grid_cursor_sprite.modulate.a = 0.5 # Keep opacity at 50% + grid_cursor_sprite.modulate.a = 0.3 # Keep opacity at 30% # Update player facing direction based on mouse position (use world position) + # Only update if mouse is inside the window viewport + var viewport_rect = get_viewport().get_visible_rect() + var mouse_in_window = viewport_rect.has_point(mouse_pos) + if local_players.size() > 0: var player = local_players[0] # Use first local player if player and is_instance_valid(player) and player.is_local_player: - var player_pos = player.global_position - # Use grid-locked position if available, otherwise use free mouse position - var target_world_pos = grid_locked_world_pos if show_grid_cursor else world_pos - var mouse_direction = (target_world_pos - player_pos).normalized() - - # Only update facing if mouse is far enough from player - if mouse_direction.length() > 0.1: - player._update_facing_from_mouse(mouse_direction) + if mouse_in_window: + # Mouse is in window - use mouse for direction control + var player_pos = player.global_position + # Use grid-locked position if available, otherwise use free mouse position + var target_world_pos = grid_locked_world_pos if show_grid_cursor else world_pos + var mouse_direction = (target_world_pos - player_pos).normalized() + + # Only update facing if mouse is far enough from player + if mouse_direction.length() > 0.1: + player._update_facing_from_mouse(mouse_direction) + else: + # Mouse is outside window - disable mouse control (use WASD/movement for direction) + if "mouse_control_active" in player: + player.mouse_control_active = false func _init_fog_of_war(): if dungeon_data.is_empty() or not dungeon_data.has("map_size"): @@ -2356,6 +2367,9 @@ func _generate_dungeon(): # Spawn interactable objects _spawn_interactable_objects() + # Spawn traps + _spawn_traps() + # Spawn blocking doors _spawn_blocking_doors() @@ -3310,6 +3324,7 @@ func _pack_dungeon_blob(): "torches": dungeon_data.get("torches", []), "enemies": dungeon_data.get("enemies", []), "interactable_objects": dungeon_data.get("interactable_objects", []), + "traps": dungeon_data.get("traps", []), "stairs": dungeon_data.get("stairs", {}), "blocking_doors": dungeon_data.get("blocking_doors", []) } @@ -3318,8 +3333,9 @@ func _pack_dungeon_blob(): var enemy_count = full_dungeon_data.enemies.size() if full_dungeon_data.enemies is Array else 0 var torch_count = full_dungeon_data.torches.size() if full_dungeon_data.torches is Array else 0 var object_count = full_dungeon_data.interactable_objects.size() if full_dungeon_data.interactable_objects is Array else 0 - print("GameWorld: HOST - Packing dungeon blob with: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects") - LogManager.log("GameWorld: Packing dungeon blob - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count), LogManager.CATEGORY_DUNGEON) + var trap_count = full_dungeon_data.traps.size() if full_dungeon_data.traps is Array else 0 + print("GameWorld: HOST - Packing dungeon blob with: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects, ", trap_count, " traps") + LogManager.log("GameWorld: Packing dungeon blob - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count) + ", traps: " + str(trap_count), LogManager.CATEGORY_DUNGEON) # Store only STATIC metadata (dynamic state is collected on-demand when syncing to clients) # This ensures joiners always get the current world state, not what it was when blob was packed @@ -3414,6 +3430,12 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h # Clear previous level on client _clear_level() + # Reset all player grab/lift/push states to prevent being stuck in lifting animation + var all_players = get_tree().get_nodes_in_group("player") + for player in all_players: + if player and is_instance_valid(player) and player.has_method("reset_grab_state"): + player.reset_grab_state() + # Wait for old entities to be fully freed before spawning new ones await get_tree().process_frame await get_tree().process_frame # Wait an extra frame to ensure cleanup is complete @@ -4015,6 +4037,12 @@ func _check_and_render_dungeon(): print("GameWorld: Client - Dungeon data assembled! Rendering...") LogManager.log("GameWorld: Client assembled dungeon from chunks for level " + str(current_level), LogManager.CATEGORY_DUNGEON) + # Reset all player grab/lift/push states to prevent being stuck in lifting animation + var all_players = get_tree().get_nodes_in_group("player") + for player in all_players: + if player and is_instance_valid(player) and player.has_method("reset_grab_state"): + player.reset_grab_state() + # Fix player appearance _fix_player_appearance_after_dungeon_sync() @@ -4348,6 +4376,62 @@ func _spawn_enemies(): else: print("GameWorld: [CLEANUP] No defeated enemies found in scene to remove (all were properly skipped during spawn)") +func _spawn_traps(): + # Spawn traps from dungeon data + if dungeon_data.is_empty() or not dungeon_data.has("traps"): + LogManager.log("GameWorld: No traps to spawn", LogManager.CATEGORY_DUNGEON) + return + + var is_server = multiplayer.is_server() or not multiplayer.has_multiplayer_peer() + + var entities_node = get_node_or_null("Entities") + if not entities_node: + push_error("ERROR: Could not find Entities node!") + return + + # Remove existing traps first (avoid name conflicts / desync) + var traps_to_remove = [] + for child in entities_node.get_children(): + if child.is_in_group("trap"): + traps_to_remove.append(child) + + for trap in traps_to_remove: + print("GameWorld: Removing old trap: ", trap.name) + if is_instance_valid(trap): + trap.queue_free() + + # Wait a frame to ensure removals are processed + if traps_to_remove.size() > 0: + await get_tree().process_frame + + var traps = dungeon_data.traps + if traps == null or not traps is Array: + print("GameWorld: WARNING: dungeon_data.traps is not an Array!") + return + + LogManager.log("GameWorld: Spawning " + str(traps.size()) + " trap(s)", LogManager.CATEGORY_DUNGEON) + + var trap_scene = load("res://scenes/trap.tscn") + if not trap_scene: + push_error("ERROR: Could not load trap scene!") + return + + for i in range(traps.size()): + var trap_data = traps[i] + if not trap_data is Dictionary: + continue + + var trap = trap_scene.instantiate() + trap.name = "Trap_" + str(i) + trap.global_position = trap_data.position + + # Set multiplayer authority to server + if is_server: + trap.set_multiplayer_authority(1) # Server is authority + + entities_node.add_child(trap, true) + LogManager.log("GameWorld: Spawned trap at " + str(trap_data.position), LogManager.CATEGORY_DUNGEON) + func _spawn_interactable_objects(): # Spawn interactable objects from dungeon data if dungeon_data.is_empty() or not dungeon_data.has("interactable_objects"): @@ -5549,6 +5633,12 @@ func _move_all_players_to_start_room(): if dungeon_data.is_empty() or not dungeon_data.has("start_room"): return + # Reset all player grab/lift/push states to prevent being stuck in lifting animation + var all_players = get_tree().get_nodes_in_group("player") + for player in all_players: + if player and is_instance_valid(player) and player.has_method("reset_grab_state"): + player.reset_grab_state() + var start_room = dungeon_data.start_room _update_spawn_points(start_room) diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 1ba094c..ab7e89b 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -18,6 +18,7 @@ var held_by_player = null var is_frozen: bool = false var thrown_by_player = null # Track who threw this box var is_broken: bool = false +var has_dealt_damage: bool = false # Track if this thrown object has already damaged something # Physics for thrown objects var throw_velocity: Vector2 = Vector2.ZERO @@ -129,6 +130,7 @@ func _land(): is_being_held = false # Make sure it can be grabbed again held_by_player = null thrown_by_player = null # Clear who threw it + has_dealt_damage = false # Reset damage flag for next throw # Re-enable collision when landing set_collision_layer_value(2, true) @@ -158,15 +160,20 @@ func _land(): func _handle_air_collision(): # Handle collision while airborne + # CRITICAL: Only allow ONE damage event per throw + if has_dealt_damage: + return + for i in get_slide_collision_count(): var collision = get_slide_collision(i) var collider = collision.get_collider() - # Pot special case: break on wall collision - if object_type == "Pot" and _is_wall_collider(collider): + # Break on wall collision (pots and boxes) + if (object_type == "Pot" or object_type == "Box") and _is_wall_collider(collider): # Only process on server to prevent duplicates if not multiplayer.is_server(): continue + has_dealt_damage = true # Mark as dealt damage (wall hit counts) if is_destroyable: if multiplayer.has_multiplayer_peer(): var game_world = get_tree().get_first_node_in_group("game_world") @@ -181,11 +188,15 @@ func _handle_air_collision(): if not multiplayer.is_server(): continue - # Damage enemy + has_dealt_damage = true # Mark as dealt damage - can't damage anything else now + + # Damage enemy (pots deal less damage than boxes) + # Enemy's take_damage() already handles defense calculation if collider.has_method("take_damage"): var attacker_pos = thrown_by_player.global_position if thrown_by_player and is_instance_valid(thrown_by_player) else global_position - collider.take_damage(15.0, attacker_pos) - print(name, " hit enemy ", collider.name, "!") + var base_damage = 10.0 if object_type == "Pot" else 15.0 + collider.take_damage(base_damage, attacker_pos) + print(name, " hit enemy ", collider.name, " with thrown object (", base_damage, " base damage, defense will reduce)!") # Box breaks (only if destroyable) if is_destroyable: @@ -209,6 +220,8 @@ func _handle_air_collision(): if not multiplayer.is_server(): continue + has_dealt_damage = true # Mark as dealt damage - can't damage anything else now + # Hit a player! Break locally and sync to others (only if destroyable) if is_destroyable: # Sync break to OTHER clients via RPC BEFORE breaking locally @@ -220,23 +233,25 @@ func _handle_air_collision(): _break_into_pieces() - # Damage and knockback player using RPC + # Damage and knockback player using RPC (pots deal less damage than boxes) + # Player's take_damage() already handles defense calculation # Pass the thrower's position for accurate direction if collider.has_method("rpc_take_damage"): var attacker_pos = thrown_by_player.global_position if thrown_by_player and is_instance_valid(thrown_by_player) else global_position + var base_damage = 7.0 if object_type == "Pot" else 10.0 var player_peer_id = collider.get_multiplayer_authority() if player_peer_id != 0: # If target peer is the same as server (us), call directly # rpc_id() might not execute locally when called to same peer if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): # Call directly on the same peer - collider.rpc_take_damage(10.0, attacker_pos) + collider.rpc_take_damage(base_damage, attacker_pos) else: # Send RPC to remote peer - collider.rpc_take_damage.rpc_id(player_peer_id, 10.0, attacker_pos) + collider.rpc_take_damage.rpc_id(player_peer_id, base_damage, attacker_pos) else: # Fallback: broadcast if we can't get peer_id - collider.rpc_take_damage.rpc(10.0, attacker_pos) + collider.rpc_take_damage.rpc(base_damage, attacker_pos) print(name, " hit player ", collider.name, "!") return @@ -245,6 +260,8 @@ func _handle_air_collision(): if not multiplayer.is_server(): continue + has_dealt_damage = true # Mark as dealt damage - can't damage anything else now + # Hit another box! Break both locally (only if destroyable) if is_destroyable: # Sync break to OTHER clients via RPC BEFORE breaking locally @@ -416,6 +433,7 @@ func on_grabbed(by_player): is_being_held = true held_by_player = by_player + has_dealt_damage = false # Reset damage flag when picked up print(name, " grabbed by ", by_player.name) func on_lifted(by_player): @@ -434,6 +452,7 @@ func on_released(by_player): position_z = 0.0 velocity_z = 0.0 throw_velocity = Vector2.ZERO + has_dealt_damage = false # Reset damage flag when released # Re-enable collision (in case it was disabled) set_collision_layer_value(2, true) @@ -459,6 +478,7 @@ func on_thrown(by_player, force: Vector2): held_by_player = null thrown_by_player = by_player # Remember who threw this is_frozen = false + has_dealt_damage = false # Reset damage flag - this throw can deal damage to ONE target # Set throw velocity (affected by weight) - increased for longer arc throw_velocity = force / weight @@ -495,7 +515,7 @@ func setup_pot(): can_be_pushed = true is_destroyable = true is_liftable = true - weight = 1.0 + weight = 0.8 # Pots are very light and easy to throw far! var pot_frames = [1, 2, 3, 20, 21, 22, 58] if sprite: @@ -554,7 +574,7 @@ func setup_box(): can_be_pushed = true is_destroyable = true is_liftable = true - weight = 1.0 + weight = 1.5 # Boxes are heavier than pots var box_frames = [7, 26] if sprite: diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index 687aa72..7810ecb 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -38,6 +38,11 @@ var equipment_selection_index: int = 0 # Current equipment slot index (0-5: main @onready var sfx_food: AudioStreamPlayer2D = $SfxFood @onready var sfx_armour: AudioStreamPlayer2D = $SfxArmour +# Weight UI elements (created programmatically) +var weight_container: HBoxContainer = null +var weight_label: Label = null +var weight_progress_bar: ProgressBar = null + # Store button/item mappings for selection highlighting var inventory_buttons: Dictionary = {} # item -> button var equipment_buttons: Dictionary = {} # slot_name -> button @@ -75,6 +80,9 @@ func _ready(): # Create equipment slot buttons (dynamically) _create_equipment_slots() + # Create weight progress bar + _create_weight_ui() + # Setup selection rectangle (already in scene, just configure it) _setup_selection_rectangle() @@ -185,9 +193,26 @@ func _update_stats(): str(char_stats.defense) + "\n" + \ str(char_stats.move_speed) + "\n" + \ str(char_stats.attack_speed) + "\n" + \ - str(char_stats.sight) + "\n" + \ - str(char_stats.spell_amp) + "\n" + \ - str(char_stats.crit_chance) + "%" + str(char_stats.sight) + + # Update weight progress bar + if weight_progress_bar and weight_label: + var current_weight = char_stats.get_total_weight() + var max_weight = char_stats.get_carrying_capacity() + weight_progress_bar.max_value = max_weight + weight_progress_bar.value = current_weight + weight_label.text = "Weight: " + str(int(current_weight)) + "/" + str(int(max_weight)) + + # Change color based on weight (green -> yellow -> red) + var weight_ratio = current_weight / max_weight + var fill_style = StyleBoxFlat.new() + if weight_ratio < 0.7: + fill_style.bg_color = Color(0.6, 0.8, 0.3) # Green + elif weight_ratio < 0.9: + fill_style.bg_color = Color(0.9, 0.8, 0.2) # Yellow + else: + fill_style.bg_color = Color(0.9, 0.3, 0.2) # Red + weight_progress_bar.add_theme_stylebox_override("fill", fill_style) func _create_equipment_slots(): # Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory @@ -244,6 +269,46 @@ func _create_equipment_slots(): equipment_slots[slot_name] = button equipment_buttons[slot_name] = button +func _create_weight_ui(): + # Create weight display (label + progress bar) + if not stats_panel: + return + + # Create container for weight UI + weight_container = HBoxContainer.new() + weight_container.name = "WeightContainer" + weight_container.add_theme_constant_override("separation", 4) + + # Create label + weight_label = Label.new() + weight_label.text = "Weight:" + weight_label.add_theme_font_size_override("font_size", 10) + if ResourceLoader.exists("res://assets/fonts/standard_font.png"): + var font_resource = load("res://assets/fonts/standard_font.png") + if font_resource: + weight_label.add_theme_font_override("font", font_resource) + weight_container.add_child(weight_label) + + # Create progress bar + weight_progress_bar = ProgressBar.new() + weight_progress_bar.custom_minimum_size = Vector2(100, 12) + weight_progress_bar.show_percentage = false + # Style the progress bar + var progress_style = StyleBoxFlat.new() + progress_style.bg_color = Color(0.2, 0.2, 0.2, 0.8) + progress_style.border_color = Color(0.4, 0.4, 0.4) + progress_style.set_border_width_all(1) + weight_progress_bar.add_theme_stylebox_override("background", progress_style) + + var fill_style = StyleBoxFlat.new() + fill_style.bg_color = Color(0.6, 0.8, 0.3) # Green color + weight_progress_bar.add_theme_stylebox_override("fill", fill_style) + + weight_container.add_child(weight_progress_bar) + + # Add to stats panel (after stats labels) + stats_panel.add_child(weight_container) + func _has_equipment_in_slot(slot_name: String) -> bool: # Check if there's an item equipped in this slot if not local_player or not local_player.character_stats: @@ -453,14 +518,20 @@ func _update_ui(): if equipped_item.can_have_multiple_of and equipped_item.quantity > 1: var quantity_label = Label.new() quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - quantity_label.size = Vector2(24, 24) - quantity_label.custom_minimum_size = Vector2(0, 0) - quantity_label.position = Vector2(10, 2) + quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP + quantity_label.size = Vector2(36, 36) + quantity_label.custom_minimum_size = Vector2(36, 36) + quantity_label.position = Vector2(0, 0) quantity_label.text = str(equipped_item.quantity) - if quantity_font: - quantity_label.add_theme_font_override("font", quantity_font) - quantity_label.add_theme_font_size_override("font_size", 8) - quantity_label.scale = Vector2(0.5, 0.5) + # Use dmg_numbers.png font (same as inventory items) + var dmg_font_resource = load("res://assets/fonts/dmg_numbers.png") + if dmg_font_resource: + var font_file = FontFile.new() + 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.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) # Update inventory grid - clear existing HBoxContainers @@ -727,6 +798,13 @@ func _format_item_info(item: Item) -> String: text += ", ".join(stat_lines) text += "\n\n" + # Weight + var item_weight = item.weight + if item.can_have_multiple_of: + item_weight *= item.quantity + text += "Weight: %.1f" % item_weight + text += "\n\n" + # Controls if item.item_type == Item.ItemType.Equippable: if selected_type == "equipment": @@ -903,6 +981,7 @@ func _on_inventory_item_pressed(item: Item): _update_selection_highlight() _update_selection_rectangle() + _update_info_panel() # Show item description on single-click func _on_inventory_item_gui_input(event: InputEvent, item: Item): # Handle double-click to equip/consume and right-click to drop diff --git a/src/scripts/item.gd b/src/scripts/item.gd index 20f5114..7b2538e 100644 --- a/src/scripts/item.gd +++ b/src/scripts/item.gd @@ -55,7 +55,7 @@ var weapon_type: WeaponType = WeaponType.NONE var two_handed:bool = false var quantity = 1 var can_have_multiple_of:bool = false -var weight: float = 1.0 # Item weight for encumbrance system +var weight: float = 2.0 # Item weight for encumbrance system (default 2.0 for equipment) func save(): var json = { diff --git a/src/scripts/item_database.gd b/src/scripts/item_database.gd index 6948874..de5b7e9 100644 --- a/src/scripts/item_database.gd +++ b/src/scripts/item_database.gd @@ -76,7 +76,8 @@ static func _load_all_items(): "modifiers": {"def": 3}, "buy_cost": 80, "sell_worth": 24, - "rarity": ItemRarity.COMMON + "rarity": ItemRarity.COMMON, + "weight": 4.0 # Light armour }) _register_item("plate", { @@ -90,7 +91,8 @@ static func _load_all_items(): "modifiers": {"def": 5, "end": 1}, "buy_cost": 150, "sell_worth": 45, - "rarity": ItemRarity.UNCOMMON + "rarity": ItemRarity.UNCOMMON, + "weight": 10.0 # Heavy armour! }) _register_item("full_mail", { @@ -326,7 +328,8 @@ static func _load_all_items(): "modifiers": {"def": 2}, "buy_cost": 40, "sell_worth": 12, - "rarity": ItemRarity.COMMON + "rarity": ItemRarity.COMMON, + "weight": 1.5 # Boots are light }) _register_item("sturdy_boots", { @@ -450,7 +453,8 @@ static func _load_all_items(): "modifiers": {"dmg": 5}, "buy_cost": 100, "sell_worth": 30, - "rarity": ItemRarity.COMMON + "rarity": ItemRarity.COMMON, + "weight": 3.5 }) _register_item("sword_of_blaze", { @@ -935,7 +939,8 @@ static func _load_all_items(): "modifiers": {"dmg": 4, "dex": 2}, "buy_cost": 100, "sell_worth": 30, - "rarity": ItemRarity.COMMON + "rarity": ItemRarity.COMMON, + "weight": 2.5 # Bows are moderate weight }) _register_item("dark_bow", { @@ -998,12 +1003,14 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.AMMUNITION, "spriteFrame": 7 * 20 + 11, # 11,7 - "quantity": 13, + "quantity": 15, # Increased from 13 to 15 "can_have_multiple_of": true, "modifiers": {"dmg": 2}, "buy_cost": 20, "sell_worth": 6, - "rarity": ItemRarity.COMMON + "rarity": ItemRarity.COMMON, + "weight": 0.1, # Very light in inventory (arrows are light!) + "drop_chance": 15.0 # Much higher drop chance = drops MUCH more often! }) # CONSUMABLE FOOD ITEMS (row 7) @@ -1258,22 +1265,37 @@ static func create_item(item_id: String) -> Item: return item -# Get a random item by rarity +# Get a random item by rarity (weighted by item drop_chance field) static func get_random_item_by_rarity(rarity: ItemRarity) -> Item: _initialize() var candidates = [] + var weights = [] + var total_weight = 0.0 + for item_id in item_definitions.keys(): var item_data = item_definitions[item_id] if item_data.has("rarity") and item_data["rarity"] == rarity: candidates.append(item_id) + # Use drop_chance field if present, otherwise default to 1.0 + var drop_chance = item_data.get("drop_chance", 1.0) + weights.append(drop_chance) + total_weight += drop_chance if candidates.is_empty(): # Fallback to common items return get_random_item_by_rarity(ItemRarity.COMMON) - var random_item_id = candidates[randi() % candidates.size()] - return create_item(random_item_id) + # Weighted random selection based on drop_chance + var roll = randf() * total_weight + var cumulative = 0.0 + for i in range(candidates.size()): + cumulative += weights[i] + if roll <= cumulative: + return create_item(candidates[i]) + + # Fallback (shouldn't reach here) + return create_item(candidates[candidates.size() - 1]) # Get a random item (weighted by rarity) static func get_random_item() -> Item: diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 0c7400b..f420ae2 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -54,6 +54,9 @@ var item: Item = null # Item instance (for LootType.ITEM) @onready var sfx_banana_collect = $SfxBananaCollect @onready var sfx_key_collect = $SfxKeyCollect +# Quantity badge for items with quantity > 1 +var quantity_badge: Label = null + func _ready(): add_to_group("loot") @@ -150,6 +153,10 @@ func _setup_sprite(): sprite.vframes = item.spriteFrames.y if item.spriteFrames.y > 0 else 14 sprite.frame = item.spriteFrame print("Loot: Set up item sprite for ", item.item_name, " frame=", sprite.frame) + + # Add quantity badge if quantity > 1 + if item.quantity > 1: + _create_quantity_badge(item.quantity) else: print("Loot: ERROR - Could not load texture from spritePath: ", item.spritePath) else: @@ -179,6 +186,18 @@ func _setup_collision_shape(): collision_shape.shape = circle_shape +func _create_quantity_badge(quantity: int): + # Create a label to show the quantity + quantity_badge = Label.new() + quantity_badge.text = str(quantity) + quantity_badge.add_theme_font_size_override("font_size", 8) + quantity_badge.add_theme_color_override("font_color", Color.WHITE) + quantity_badge.add_theme_color_override("font_outline_color", Color.BLACK) + quantity_badge.add_theme_constant_override("outline_size", 2) + quantity_badge.z_index = 100 # Above the sprite + quantity_badge.position = Vector2(6, -8) # Bottom right of sprite + add_child(quantity_badge) + func _physics_process(delta): # Stop all physics processing if collected if collected: diff --git a/src/scripts/player.gd b/src/scripts/player.gd index d92817a..b8d0e3f 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -8,7 +8,7 @@ var appearance_rng: RandomNumberGenerator # Deterministic RNG for appearance/sta @export var move_speed: float = 80.0 @export var grab_range: float = 20.0 -@export var throw_force: float = 150.0 +@export var base_throw_force: float = 80.0 # Base throw force (reduced from 150), scales with STR @export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider) # Network identity @@ -25,6 +25,7 @@ var input_device: int = -1 # -1 for keyboard, 0+ for gamepad index var virtual_joystick_input: Vector2 = Vector2.ZERO # Virtual joystick input from mobile controls var was_mouse_right_pressed: bool = false # Track previous mouse right button state var was_mouse_left_pressed: bool = false # Track previous mouse left button state +var mouse_control_active: bool = false # True when mouse is controlling facing direction # Interaction var held_object = null @@ -63,6 +64,9 @@ var knockback_duration: float = 0.3 # How long knockback lasts var can_attack: bool = true var attack_cooldown: float = 0.0 # No cooldown - instant attacks! var is_attacking: bool = false +var is_charging_bow: bool = false # True when holding attack with bow+arrows +var bow_charge_start_time: float = 0.0 +var bow_charge_percentage: float = 1.0 # 0.5, 0.75, or 1.0 based on charge time 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 @@ -167,6 +171,12 @@ const ANIMATIONS = { "loop": false, "nextAnimation": "IDLE" }, + "BOW_STRING": { + "frames": [9], + "frameDurations": [30], + "loop": true, + "nextAnimation": null, + }, "BOW": { "frames": [9, 10, 11, 12], "frameDurations": [80, 110, 110, 80], @@ -244,6 +254,7 @@ const ANIMATIONS = { var current_animation = "IDLE" var current_frame = 0 var current_direction = Direction.DOWN +var facing_direction_vector: Vector2 = Vector2.DOWN # Full 360-degree facing direction for attacks var time_since_last_frame = 0.0 func _ready(): @@ -253,6 +264,9 @@ func _ready(): # Set respawn point to starting position respawn_point = global_position + # Initialize facing direction vector based on current direction + facing_direction_vector = Vector2.DOWN + # Set up input device based on local player index if is_local_player: if local_player_index == 0: @@ -268,7 +282,10 @@ func _ready(): _duplicate_sprite_materials() # Set up player appearance (randomized based on stats) - _setup_player_appearance() + # ONLY run this for the authority (owner of this player) + # Remote players will receive appearance via _sync_equipment and character_changed signal + if is_multiplayer_authority(): + _setup_player_appearance() # Authority is set by player_manager after adding to scene @@ -552,27 +569,31 @@ func _randomize_stats(): character_stats.baseStats.wis = appearance_rng.randi_range(8, 12) character_stats.baseStats.cha = appearance_rng.randi_range(8, 12) character_stats.baseStats.lck = appearance_rng.randi_range(8, 12) + character_stats.baseStats.per = appearance_rng.randi_range(8, 12) # Apply race-based stat modifiers match character_stats.race: "Dwarf": - # Dwarf: Higher STR, Medium DEX, lower INT, lower WIS, lower LCK + # Dwarf: Higher STR, Medium DEX, lower INT, lower WIS, lower LCK, Medium PER (for disarming) character_stats.baseStats.str += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.wis -= 2 character_stats.baseStats.lck -= 2 + character_stats.baseStats.per += 1 "Elf": - # Elf: Medium STR, Higher DEX, lower INT, medium WIS, higher LCK + # Elf: Medium STR, Higher DEX, lower INT, medium WIS, higher LCK, Highest PER (trap detection) character_stats.baseStats.dex += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.lck += 2 + character_stats.baseStats.per += 4 # Highest perception for trap detection "Human": - # Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK + # Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK, Lower PER character_stats.baseStats.str -= 2 character_stats.baseStats.dex -= 2 character_stats.baseStats.int += 3 character_stats.baseStats.wis += 3 character_stats.baseStats.lck -= 2 + character_stats.baseStats.per -= 1 # Stats randomized (verbose logging removed) @@ -593,6 +614,16 @@ func _setup_player_appearance(): # Randomize stats AFTER race is set (race affects stat modifiers) _randomize_stats() + # Give Elf race starting bow and arrows + if selected_race == "Elf": + var starting_bow = ItemDatabase.create_item("short_bow") + var starting_arrows = ItemDatabase.create_item("arrow") + if starting_bow and starting_arrows: + starting_arrows.quantity = 3 + character_stats.equipment["mainhand"] = starting_bow + character_stats.equipment["offhand"] = starting_arrows + print("Elf player ", name, " spawned with short bow and 3 arrows") + # Randomize skin (human only for players) # Weighted random: Human1 has highest chance, Human7 has lowest chance # Weights: Human1=7, Human2=6, Human3=5, Human4=4, Human5=3, Human6=2, Human7=1 (total=28) @@ -724,6 +755,12 @@ func _setup_player_appearance(): # Apply appearance to sprite layers _apply_appearance_to_sprites() + + # Emit character_changed to trigger equipment/race sync + if character_stats: + character_stats.character_changed.emit(character_stats) + + print("Player ", name, " appearance set up: race=", character_stats.race) func _apply_appearance_to_sprites(): # Apply character_stats appearance to sprite layers @@ -998,6 +1035,9 @@ func _on_character_changed(_char: CharacterStats): else: equipment_data[slot_name] = null _rpc_to_ready_peers("_sync_equipment", [equipment_data]) + + # Sync race and base stats to all clients (for proper display) + _rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()]) # Sync equipment and inventory to client (when server adds/removes items for a client player) # This ensures joiners see items they pick up and equipment changes @@ -1221,8 +1261,15 @@ func _update_animation(delta): sprite_addons.frame = frame_index if sprite_headgear: sprite_headgear.frame = frame_index + + # Update weapon sprite - use BOW_STRING animation if charging bow if sprite_weapon: - sprite_weapon.frame = frame_index + if is_charging_bow: + # Show BOW_STRING animation on weapon sprite only + var bow_string_frame = current_direction * 35 + ANIMATIONS["BOW_STRING"]["frames"][0] + sprite_weapon.frame = bow_string_frame + else: + sprite_weapon.frame = frame_index func _get_direction_from_vector(vec: Vector2) -> int: if vec.length() < 0.1: @@ -1263,6 +1310,13 @@ func _update_facing_from_mouse(mouse_direction: Vector2): if is_pushing: return + # Mark that mouse control is active (prevents movement keys from overriding attack direction) + mouse_control_active = true + + # Store full 360-degree direction for attacks + if mouse_direction.length() > 0.1: + facing_direction_vector = mouse_direction.normalized() + var new_direction = _get_direction_from_vector(mouse_direction) as Direction # Update direction and cone light rotation if changed @@ -1702,19 +1756,25 @@ func _handle_input(): if input_vector.length() > 0.1: last_movement_direction = input_vector.normalized() - # Update facing direction (except when pushing - locked direction) - # Note: Mouse control will override this if mouse is being used - var new_direction = current_direction - if not is_pushing: - new_direction = _get_direction_from_vector(input_vector) as Direction - else: - # Keep locked direction when pushing - new_direction = push_direction_locked as Direction + # Update full 360-degree facing direction for attacks (gamepad/keyboard input) + # Only update if mouse control is not active (i.e., mouse is outside window or using gamepad) + if not is_pushing and (not mouse_control_active or input_device != -1): + facing_direction_vector = input_vector.normalized() - # Update direction and cone light rotation if changed - if new_direction != current_direction: - current_direction = new_direction - _update_cone_light_rotation() + # Update facing direction for animations (except when pushing - locked direction) + # Only update from movement input if mouse control is not active or using gamepad + if not is_pushing and (not mouse_control_active or input_device != -1): + var new_direction = _get_direction_from_vector(input_vector) as Direction + + # Update direction and cone light rotation if changed + if new_direction != current_direction: + current_direction = new_direction + _update_cone_light_rotation() + elif is_pushing: + # Keep locked direction when pushing + if push_direction_locked != current_direction: + current_direction = push_direction_locked as Direction + _update_cone_light_rotation() # Set animation based on state if is_lifting: @@ -1817,6 +1877,36 @@ func _handle_interactions(): else: grab_just_released = false + # Cancel bow charging if grab is pressed + if grab_just_pressed and is_charging_bow: + is_charging_bow = false + + # Sync bow charge end to other clients + if multiplayer.has_multiplayer_peer(): + _sync_bow_charge_end.rpc() + + print(name, " cancelled bow charge") + + # Check for trap disarm (Dwarf only) + if character_stats and character_stats.race == "Dwarf": + var nearby_trap = _get_nearby_disarmable_trap() + if nearby_trap: + if grab_just_pressed: + # Start disarming + nearby_trap.disarming_player = self + nearby_trap.disarm_progress = 0.0 + print(name, " (Dwarf) started disarming trap") + elif grab_just_released: + # Cancel disarm if released early + if nearby_trap.disarming_player == self: + nearby_trap._cancel_disarm() + print(name, " (Dwarf) cancelled disarm") + # Don't process regular grab actions if near trap + if grab_button_down: + # Skip grab handling below + just_grabbed_this_frame = false + return + # Track how long grab button is held if grab_button_down: grab_button_pressed_time += get_process_delta_time() @@ -1839,14 +1929,8 @@ func _handle_interactions(): 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 - check if moving to throw, or just put down - var is_moving = velocity.length() > 10.0 - if is_moving: - # Moving + tap E = throw - _throw_object() - else: - # Not moving + tap E = put down - _place_down_object() + # Already lifting - always place down (throwing is now only via attack button) + _place_down_object() # Handle grab button release # CRITICAL: Don't process release if: @@ -1926,27 +2010,108 @@ func _handle_interactions(): _start_pushing() # Lift will only happen on release if it was a quick tap - # Handle attack input + # Handle attack input with bow charging mechanic + var attack_pressed = false var attack_just_pressed = false + var attack_just_released = false + if input_device == -1: # Keyboard or Mouse - var mouse_left_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) - attack_just_pressed = Input.is_action_just_pressed("attack") or (mouse_left_pressed and not was_mouse_left_pressed) - was_mouse_left_pressed = mouse_left_pressed + attack_pressed = Input.is_action_pressed("attack") + attack_just_pressed = Input.is_action_just_pressed("attack") + attack_just_released = Input.is_action_just_released("attack") else: # Gamepad (X button) - attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X) + attack_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X) + attack_just_pressed = attack_pressed and not is_attacking and not is_charging_bow + # For gamepad, detect release by checking if was pressing last frame + attack_just_released = not attack_pressed and is_charging_bow - if attack_just_pressed and can_attack: - if is_lifting: - # Attack while lifting -> throw immediately (no movement required) - _force_throw_held_object(last_movement_direction) - elif not is_pushing: + # Check if player has bow + arrows equipped + var has_bow_and_arrows = false + var equipped_weapon = null + var equipped_arrows = null + if character_stats and character_stats.equipment.has("mainhand") and character_stats.equipment.has("offhand"): + equipped_weapon = character_stats.equipment["mainhand"] + equipped_arrows = character_stats.equipment["offhand"] + if equipped_weapon and equipped_arrows: + if equipped_weapon.weapon_type == Item.WeaponType.BOW and equipped_arrows.weapon_type == Item.WeaponType.AMMUNITION and equipped_arrows.quantity > 0: + has_bow_and_arrows = true + + # Handle bow charging + if has_bow_and_arrows and not is_lifting and not is_pushing: + if attack_just_pressed and can_attack and not is_charging_bow: + # Start charging bow + is_charging_bow = true + bow_charge_start_time = Time.get_ticks_msec() / 1000.0 + + # Sync bow charge start to other clients + if multiplayer.has_multiplayer_peer(): + _sync_bow_charge_start.rpc() + + print(name, " started charging bow") + elif attack_just_released and is_charging_bow: + # Calculate charge time + var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time + + # Minimum charge time: 0.2 seconds, otherwise cancel + if charge_time < 0.2: + is_charging_bow = false + print(name, " cancelled arrow (released too quickly, need at least 0.2s)") + return + + # Smooth curve: charge from 0.2s to 1.0s + # Speed scales from 50% to 100% (160 to 320 speed) + var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s + bow_charge_percentage = 0.5 + (charge_progress * 0.5) # 0.5 to 1.0 + + # Release bow and shoot + is_charging_bow = false + + # Sync bow charge end to other clients + if multiplayer.has_multiplayer_peer(): + _sync_bow_charge_end.rpc() + _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) + if is_charging_bow: + is_charging_bow = false + + # 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() # 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 +func _get_nearby_disarmable_trap() -> Node: + # Check for nearby trap that can be disarmed (Dwarf only) + var traps = get_tree().get_nodes_in_group("trap") + for trap in traps: + if not trap or not is_instance_valid(trap): + continue + + # Check if trap is detected, not disarmed, and within disarm range + if trap.is_detected and not trap.is_disarmed: + var distance = global_position.distance_to(trap.global_position) + # Check if within disarm area range (approximate - trap's DisarmArea has radius ~17) + if distance < 20: + return trap + + return null + func _try_grab(): if not grab_area: return @@ -2102,8 +2267,9 @@ func _start_pushing(): # Snap to one of 8 directions push_axis = _snap_to_8_directions(initial_direction) - # Lock the facing direction + # Lock the facing direction (for both animation and attacks) push_direction_locked = _get_direction_from_vector(push_axis) + facing_direction_vector = push_axis.normalized() # Re-enable collision with walls (layer 7) for pushing, but keep collision with players/objects disabled if _is_box(held_object): @@ -2131,6 +2297,51 @@ func _force_drop_held_object(): # Just release _stop_pushing() +func reset_grab_state(): + # Force reset all grab/lift/push states (used when transitioning levels) + if held_object and is_instance_valid(held_object): + # Re-enable collision on held object + if _is_box(held_object): + held_object.set_collision_layer_value(2, true) + held_object.set_collision_mask_value(1, true) + held_object.set_collision_mask_value(2, true) + held_object.set_collision_mask_value(7, true) + if "is_frozen" in held_object: + held_object.is_frozen = false + if "is_being_held" in held_object: + held_object.is_being_held = false + if "held_by_player" in held_object: + held_object.held_by_player = null + elif _is_player(held_object): + held_object.set_collision_layer_value(1, true) + held_object.set_collision_mask_value(1, true) + held_object.set_collision_mask_value(7, true) + if held_object.has_method("set_being_held"): + held_object.set_being_held(false) + + # Stop drag sound if playing + if held_object.has_method("stop_drag_sound"): + held_object.stop_drag_sound() + + # Clear all state + held_object = null + grab_offset = Vector2.ZERO + grab_distance = 0.0 + is_lifting = false + is_pushing = false + push_axis = Vector2.ZERO + initial_grab_position = Vector2.ZERO + initial_player_position = Vector2.ZERO + just_grabbed_this_frame = false + grab_start_time = 0.0 + was_dragging_last_frame = false + + # Reset to idle animation + if current_animation == "IDLE_HOLD" or current_animation == "RUN_HOLD" or current_animation == "LIFT" or current_animation == "IDLE_PUSH" or current_animation == "RUN_PUSH": + _set_animation("IDLE") + + print("Reset grab state for ", name) + func _stop_pushing(): if not held_object: return @@ -2158,10 +2369,12 @@ func _stop_pushing(): released_obj.set_collision_layer_value(2, true) released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(2, true) + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! elif _is_player(released_obj): # Players: back on layer 1 released_obj.set_collision_layer_value(1, true) released_obj.set_collision_mask_value(1, true) + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"): released_obj.set_being_held(false) @@ -2183,6 +2396,14 @@ func _stop_pushing(): initial_player_position = Vector2.ZERO print("Stopped pushing") +func _get_throw_force() -> float: + # Calculate throw force based on player's STR stat + # Base: 80, +3 per STR point + var str_stat = 10.0 # Default STR + if character_stats: + str_stat = character_stats.baseStats.str + character_stats.get_pass("str") + return base_throw_force + (str_stat * 3.0) + func _throw_object(): if not held_object or not is_lifting: return @@ -2201,6 +2422,9 @@ func _throw_object(): _place_down_object() return + # Calculate throw force based on STR + var throw_force = _get_throw_force() + # Position object at player's position before throwing var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front @@ -2294,6 +2518,9 @@ func _force_throw_held_object(direction: Vector2): if throw_direction.length() < 0.1: throw_direction = Vector2.RIGHT + # Calculate throw force based on STR + var throw_force = _get_throw_force() + # Position object at player's position before throwing var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front @@ -2333,6 +2560,7 @@ func _force_throw_held_object(direction: Vector2): 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 @@ -2354,6 +2582,7 @@ func _force_throw_held_object(direction: Vector2): if thrown_obj and is_instance_valid(thrown_obj): 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! if thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) @@ -2373,8 +2602,8 @@ func _place_down_object(): if not held_object: return - # Place object in front of player based on last movement direction - var place_pos = _find_closest_place_pos(last_movement_direction, held_object) + # 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 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), ")") @@ -2397,6 +2626,7 @@ func _place_down_object(): placed_obj.set_collision_layer_value(2, true) placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(2, true) + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! # Stop movement and reset all state if "throw_velocity" in placed_obj: @@ -2417,6 +2647,7 @@ func _place_down_object(): # Player: back on layer 1 placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_mask_value(1, true) + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! placed_obj.global_position = place_pos placed_obj.velocity = Vector2.ZERO if placed_obj.has_method("set_being_held"): @@ -2460,25 +2691,8 @@ func _perform_attack(): else: _set_animation("SWORD") - # Calculate attack direction based on player's facing direction - var attack_direction = Vector2.ZERO - match current_direction: - Direction.RIGHT: - attack_direction = Vector2.RIGHT - Direction.DOWN_RIGHT: - attack_direction = Vector2(1, 1).normalized() - Direction.DOWN: - attack_direction = Vector2.DOWN - Direction.DOWN_LEFT: - attack_direction = Vector2(-1, 1).normalized() - Direction.LEFT: - attack_direction = Vector2.LEFT - Direction.UP_LEFT: - attack_direction = Vector2(-1, -1).normalized() - Direction.UP: - attack_direction = Vector2.UP - Direction.UP_RIGHT: - attack_direction = Vector2(1, -1).normalized() + # Use full 360-degree facing direction for attack + var attack_direction = facing_direction_vector.normalized() # Delay before spawning projectile await get_tree().create_timer(0.15).timeout @@ -2519,7 +2733,10 @@ func _perform_attack(): if attack_arrow_scene: var arrow_projectile = attack_arrow_scene.instantiate() get_parent().add_child(arrow_projectile) - arrow_projectile.shoot(attack_direction, global_position, self) + # Spawn arrow 4 pixels in the direction player is looking + var arrow_spawn_pos = global_position + (attack_direction * 4.0) + # Pass charge percentage to arrow (affects speed) + arrow_projectile.shoot(attack_direction, arrow_spawn_pos, self, bow_charge_percentage) # Play bow shoot sound if has_node("SfxBowShoot"): $SfxBowShoot.play() @@ -2571,7 +2788,7 @@ func _perform_attack(): # 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]) + _rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage]) # Reset attack cooldown (instant if cooldown is 0) if attack_cooldown > 0: @@ -2823,7 +3040,7 @@ 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): +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 if not is_inside_tree() or not is_instance_valid(self): @@ -2881,8 +3098,9 @@ func _sync_attack(direction: int, attack_dir: Vector2): if attack_arrow_scene: var arrow_projectile = attack_arrow_scene.instantiate() get_parent().add_child(arrow_projectile) - arrow_projectile.shoot(attack_dir, global_position, self) - print(name, " performed synced bow attack with arrow!") + # 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)") @@ -2895,6 +3113,20 @@ func _sync_attack(direction: int, attack_dir: Vector2): projectile.global_position = global_position + spawn_offset print(name, " performed synced attack!") +@rpc("any_peer", "reliable") +func _sync_bow_charge_start(): + # Sync bow charge start to other clients + if not is_multiplayer_authority(): + is_charging_bow = true + print(name, " (synced) started charging bow") + +@rpc("any_peer", "reliable") +func _sync_bow_charge_end(): + # Sync bow charge end to other clients + if not is_multiplayer_authority(): + is_charging_bow = false + print(name, " (synced) ended charging bow") + @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) @@ -3128,6 +3360,7 @@ func _sync_release(obj_name: String): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) + obj.set_collision_mask_value(7, true) # Re-enable wall collision! if "is_frozen" in obj: obj.is_frozen = false # CRITICAL: Clear is_being_held so object can be grabbed again and switches can detect it @@ -3138,6 +3371,7 @@ func _sync_release(obj_name: String): elif _is_player(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) + obj.set_collision_mask_value(7, true) # Re-enable wall collision! if obj.has_method("set_being_held"): obj.set_being_held(false) @@ -3177,6 +3411,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) + obj.set_collision_mask_value(7, true) # Re-enable wall collision! # Reset all state if "throw_velocity" in obj: @@ -3196,6 +3431,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2): elif _is_player(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) + obj.set_collision_mask_value(7, true) # Re-enable wall collision! obj.velocity = Vector2.ZERO if obj.has_method("set_being_held"): obj.set_being_held(false) @@ -3344,6 +3580,7 @@ func _force_place_down(direction: Vector2): placed_obj.set_collision_layer_value(2, true) placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(2, true) + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if "throw_velocity" in placed_obj: placed_obj.throw_velocity = Vector2.ZERO @@ -3362,6 +3599,7 @@ func _force_place_down(direction: Vector2): elif _is_player(placed_obj): placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_mask_value(1, true) + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! placed_obj.velocity = Vector2.ZERO if placed_obj.has_method("set_being_held"): placed_obj.set_being_held(false) @@ -3398,6 +3636,14 @@ func take_damage(amount: float, attacker_position: Vector2): if is_dead: return + # Cancel bow charging when taking damage + if is_charging_bow: + is_charging_bow = false + + # Sync bow charge end to other clients + if multiplayer.has_multiplayer_peer(): + _sync_bow_charge_end.rpc() + # Check for dodge chance (based on DEX) var _was_dodged = false if character_stats: @@ -3458,7 +3704,9 @@ func take_damage(amount: float, attacker_position: Vector2): velocity = direction_from_attacker * 250.0 # Scaled down for 1x scale # Face the attacker (opposite of knockback direction) - current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction + var face_direction = -direction_from_attacker + current_direction = _get_direction_from_vector(face_direction) as Direction + facing_direction_vector = face_direction.normalized() # Enable knockback state (prevents player control for a short time) is_knocked_back = true @@ -3514,6 +3762,7 @@ func _die(): released_obj.set_collision_layer_value(2, true) released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(2, true) + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if "is_being_held" in released_obj: released_obj.is_being_held = false if "held_by_player" in released_obj: @@ -3521,6 +3770,7 @@ func _die(): elif _is_player(released_obj): released_obj.set_collision_layer_value(1, true) released_obj.set_collision_mask_value(1, true) + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if released_obj.has_method("set_being_held"): released_obj.set_being_held(false) @@ -3593,6 +3843,7 @@ func _die(): # Re-enable our collision set_collision_layer_value(1, true) set_collision_mask_value(1, true) + set_collision_mask_value(7, true) # Re-enable wall collision! # THEN sync to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): @@ -3621,6 +3872,7 @@ func _respawn(): # Re-enable collision in case it was disabled while being carried set_collision_layer_value(1, true) set_collision_mask_value(1, true) + set_collision_mask_value(7, true) # Re-enable wall collision! # Reset health and state if character_stats: @@ -3748,6 +4000,7 @@ func _force_holder_to_drop_local(holder_name: String): # Re-enable collision on dropped player set_collision_layer_value(1, true) set_collision_mask_value(1, true) + set_collision_mask_value(7, true) # Re-enable wall collision! else: print(" ✗ held_object doesn't match self") else: @@ -3766,6 +4019,7 @@ func _sync_respawn(spawn_pos: Vector2): # Re-enable collision in case it was disabled while being carried set_collision_layer_value(1, true) set_collision_mask_value(1, true) + set_collision_mask_value(7, true) # Re-enable wall collision! # Just teleport and reset on clients (AFTER release is processed) global_position = spawn_pos @@ -3814,6 +4068,14 @@ func _sync_stats_update(kills_count: int, coins_count: int): character_stats.coin = coins_count print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count) +@rpc("any_peer", "reliable") +func _sync_race_and_stats(race: String, base_stats: Dictionary): + # Client receives race and base stats from authority player + if not is_multiplayer_authority(): + character_stats.race = race + character_stats.baseStats = base_stats + print(name, " race and stats synced: race=", race, " STR=", base_stats.str, " PER=", base_stats.per) + @rpc("any_peer", "reliable") func _sync_equipment(equipment_data: Dictionary): # Client receives equipment update from server or other clients @@ -3822,6 +4084,12 @@ func _sync_equipment(equipment_data: Dictionary): if not character_stats: return + # CRITICAL: Don't accept equipment syncs for our own player + # Each client manages their own equipment locally + if is_multiplayer_authority(): + print(name, " ignoring equipment sync (I'm the authority)") + return + # On server, only accept if this is a client player (not server's own player) if multiplayer.is_server(): var the_peer_id = get_multiplayer_authority() @@ -3845,15 +4113,19 @@ func _sync_equipment(equipment_data: Dictionary): func _sync_inventory(inventory_data: Array): # Client receives inventory update from server # Update inventory to match server's inventory - # Unlike _sync_equipment, we WANT to receive our own inventory from the server - # So we check if we're the server (sender) and ignore, not if we're the authority + # CRITICAL: Don't accept inventory syncs for our own player + # Each client manages their own inventory locally (same as equipment) + if is_multiplayer_authority(): + print(name, " ignoring inventory sync (I'm the authority)") + return + if multiplayer.is_server(): return # Server ignores this (it's the sender) if not character_stats: return - # Clear and rebuild inventory from server data + # Clear and rebuild inventory from server data (only for OTHER players we're viewing) character_stats.inventory.clear() for item_data in inventory_data: if item_data != null: @@ -4039,7 +4311,9 @@ func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = fa velocity = direction_from_attacker * 250.0 # Face the attacker - current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction + var face_direction = -direction_from_attacker + current_direction = _get_direction_from_vector(face_direction) as Direction + facing_direction_vector = face_direction.normalized() # Enable knockback state is_knocked_back = true diff --git a/src/scripts/staff_projectile.gd b/src/scripts/staff_projectile.gd index 428f763..8f5c2cd 100644 --- a/src/scripts/staff_projectile.gd +++ b/src/scripts/staff_projectile.gd @@ -25,8 +25,8 @@ func _ready(): _apply_color_replacements() $SfxSwosh.play() $AnimationPlayer.play("flying") - # Connect area signals - if hit_area: + # Connect area signals (only if not already connected) + if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): hit_area.body_entered.connect(_on_body_entered) func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0, staff_item: Item = null): diff --git a/src/scripts/sword_projectile.gd b/src/scripts/sword_projectile.gd index e78b27b..4bf6da1 100644 --- a/src/scripts/sword_projectile.gd +++ b/src/scripts/sword_projectile.gd @@ -21,8 +21,8 @@ var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) func _ready(): $SfxSwosh.play() - # Connect area signals - if hit_area: + # Connect area signals (only if not already connected) + if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): hit_area.body_entered.connect(_on_body_entered) func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0): diff --git a/src/scripts/trap.gd b/src/scripts/trap.gd new file mode 100644 index 0000000..e57b617 --- /dev/null +++ b/src/scripts/trap.gd @@ -0,0 +1,321 @@ +extends Node2D + +@onready var sprite = $Sprite2D +@onready var animation_player = $AnimationPlayer +@onready var activation_area = $ActivationArea +@onready var disarm_area = $DisarmArea +@onready var detection_area = $DetectionArea + +# Trap state +var is_detected: bool = false # Becomes true when any player detects it +var is_disarmed: bool = false # True if trap has been disarmed +var is_active: bool = false # True when trap is currently triggering +var has_cooldown: bool = false # Some traps can reset +var cooldown_time: float = 5.0 # Time until trap can re-activate +var cooldown_timer: float = 0.0 + +# Trap properties +var trap_damage: float = 15.0 +var trap_type: String = "Floor_Lance" + +# Per-player detection tracking (Dictionary: peer_id -> has_attempted_detection) +var player_detection_attempts: Dictionary = {} + +# Disarm tracking +var disarming_player: Node = null +var disarm_progress: float = 0.0 +var disarm_duration: float = 1.0 +var disarm_label: Label = null + +func _ready() -> void: + # Add to trap group for detection by players + add_to_group("trap") + + # Randomize trap visual based on dungeon seed + var highbox_seed = 0 + var world = get_tree().get_first_node_in_group("game_world") + if world and "dungeon_seed" in world: + highbox_seed = world.dungeon_seed + highbox_seed += int(global_position.x) * 1000 + int(global_position.y) + + var rng = RandomNumberGenerator.new() + rng.seed = highbox_seed + var index = rng.randi() % 2 + if index == 0: + sprite.texture = load("res://assets/gfx/traps/Floor_Lance.png") + trap_type = "Floor_Lance" + has_cooldown = true # Lance traps can reset + + # Start hidden (invisible until detected) + sprite.modulate.a = 0.0 + + # Setup detection area to check for players + if detection_area: + detection_area.body_entered.connect(_on_detection_area_body_entered) + detection_area.body_exited.connect(_on_detection_area_body_exited) + + # Setup disarm area to show "DISARM" text for dwarves + if disarm_area: + disarm_area.body_entered.connect(_on_disarm_area_body_entered) + disarm_area.body_exited.connect(_on_disarm_area_body_exited) + +func _process(delta: float) -> void: + # Handle cooldown timer for resetting traps + if has_cooldown and not is_active and cooldown_timer > 0: + cooldown_timer -= delta + if cooldown_timer <= 0: + # Trap has cooled down - ready to trigger again + pass + + # Handle disarm progress + if disarming_player and is_instance_valid(disarming_player): + # Check if player is still holding grab button + if disarming_player.is_multiplayer_authority() and Input.is_action_pressed("grab"): + # Play disarming sound (only if not already playing) + if $SfxDisarming.playing == false: + $SfxDisarming.play() + + disarm_progress += delta + _update_disarm_ui() + + if disarm_progress >= disarm_duration: + # Disarm complete! + _complete_disarm() + else: + # Player released grab - cancel disarm + _cancel_disarm() + +func _on_detection_area_body_entered(body: Node) -> void: + # When a player enters detection range, roll perception check (once per player per game) + if not body.is_in_group("player"): + return + + if is_detected or is_disarmed: + return # Already detected or disarmed + + # Get player peer ID + var peer_id = body.get_multiplayer_authority() + + # Check if this player has already attempted detection + if player_detection_attempts.has(peer_id): + return # Already tried once this game + + # Mark that this player has attempted detection + player_detection_attempts[peer_id] = true + + # Roll perception check (only on server/authority) + if multiplayer.is_server() or not multiplayer.has_multiplayer_peer(): + _roll_perception_check(body) + +func _on_detection_area_body_exited(_body: Node) -> void: + pass # Detection is permanent once attempted + +func _roll_perception_check(player: Node) -> void: + # Roll perception check for player + if not player or not player.character_stats: + return + + var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per") + + # Perception roll: d20 + PER modifier + # Target DC: 15 (medium difficulty) + var roll = randi() % 20 + 1 # 1d20 + var total = roll + int(per_stat / 2) - 5 # PER modifier: (PER - 10) / 2 + var dc = 15 + + print(player.name, " rolls Perception: ", roll, " + ", int(per_stat / 2) - 5, " = ", total, " vs DC ", dc) + + if total >= dc: + # Success! Player detects the trap + _detect_trap(player) + else: + # Failure - trap remains hidden to this player + print(player.name, " failed to detect trap") + +func _detect_trap(detecting_player: Node) -> void: + # Trap is detected - make it visible to ALL players + is_detected = true + + # Make trap visible + sprite.modulate.a = 1.0 + + # Sync detection to all clients (including server with call_local) + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + if multiplayer.is_server(): + _sync_trap_detected.rpc() + + print(detecting_player.name, " detected trap at ", global_position) + +@rpc("authority", "call_local", "reliable") +func _sync_trap_detected() -> void: + # Client receives trap detection notification + is_detected = true + sprite.modulate.a = 1.0 + +func _on_disarm_area_body_entered(body: Node) -> void: + # Show "DISARM" text if player is Dwarf and trap is detected + if not body.is_in_group("player"): + return + + if not is_detected or is_disarmed: + return + + # Check if player is Dwarf + if body.character_stats and body.character_stats.race == "Dwarf": + _show_disarm_text(body) + +func _on_disarm_area_body_exited(body: Node) -> void: + # Hide disarm text when player leaves area + if body == disarming_player: + _cancel_disarm() + _hide_disarm_text(body) + +func _show_disarm_text(_player: Node) -> void: + # Create "DISARM" label above trap + if disarm_label: + return # Already showing + + disarm_label = Label.new() + disarm_label.text = "DISARM" + disarm_label.add_theme_font_size_override("font_size", 16) + disarm_label.add_theme_color_override("font_color", Color.YELLOW) + disarm_label.add_theme_color_override("font_outline_color", Color.BLACK) + disarm_label.add_theme_constant_override("outline_size", 2) + disarm_label.position = Vector2(-25, -30) + disarm_label.z_index = 100 + add_child(disarm_label) + +func _hide_disarm_text(_player: Node) -> void: + if disarm_label: + disarm_label.queue_free() + disarm_label = null + +func _update_disarm_ui() -> void: + # Update disarm progress (could show radial timer here) + if disarm_label: + var progress_percent = int((disarm_progress / disarm_duration) * 100) + disarm_label.text = "DISARM (" + str(progress_percent) + "%)" + +func _cancel_disarm() -> void: + disarming_player = null + disarm_progress = 0.0 + # Stop disarming sound + if $SfxDisarming.playing: + $SfxDisarming.stop() + if disarm_label: + disarm_label.text = "DISARM" + +func _complete_disarm() -> void: + # Trap successfully disarmed! + is_disarmed = true + disarming_player = null + disarm_progress = 0.0 + + # Stop disarming sound + if $SfxDisarming.playing: + $SfxDisarming.stop() + + # Hide disarm text + _hide_disarm_text(null) + + # Disable activation area + if activation_area: + activation_area.monitoring = false + + # Show "TRAP DISARMED" in chat + var chat_ui = get_tree().get_first_node_in_group("chat_ui") + if chat_ui and chat_ui.has_method("send_system_message"): + chat_ui.send_system_message("Trap disarmed by Dwarf!") + + # Show floating text "TRAP DISARMED" + _show_floating_text("TRAP DISARMED", Color.YELLOW) + + # Change trap visual to show it's disarmed (optional - could fade out or change color) + sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) + + # Sync disarm to all clients + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + if multiplayer.is_server(): + _sync_trap_disarmed.rpc() + + print("Trap disarmed!") + +@rpc("authority", "call_local", "reliable") +func _sync_trap_disarmed() -> void: + # Client receives trap disarm notification + is_disarmed = true + sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) + if activation_area: + activation_area.monitoring = false + +func _show_floating_text(text: String, color: Color) -> void: + var floating_text_scene = preload("res://scenes/floating_text.tscn") + if floating_text_scene: + var floating_text = floating_text_scene.instantiate() + var parent = get_parent() + if parent: + parent.add_child(floating_text) + floating_text.global_position = Vector2(global_position.x, global_position.y - 20) + floating_text.setup(text, color, 0.5, 0.5, null, 1, 1, 0) + +func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_shape_index: int, _local_shape_index: int) -> void: + # Trap triggered! + if not body.is_in_group("player"): + return + + if is_disarmed or is_active: + return # Can't trigger if disarmed or already active + + if has_cooldown and cooldown_timer > 0: + return # Still on cooldown + + # Trigger trap + is_active = true + $SfxActivate.play() + animation_player.play("activate") + + # Trap is now visible to all players (once triggered) + if not is_detected: + is_detected = true + sprite.modulate.a = 1.0 + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + if multiplayer.is_server(): + _sync_trap_detected.rpc() + + # Deal damage to player (with luck-based avoidance) + _deal_trap_damage(body) + + # Start cooldown if applicable + if has_cooldown: + cooldown_timer = cooldown_time + await animation_player.animation_finished + animation_player.play("reset") + await animation_player.animation_finished + is_active = false + else: + # One-time trap - stays triggered + pass + +func _deal_trap_damage(player: Node) -> void: + if not player or not player.character_stats: + return + + # Luck-based avoidance check + var luck_stat = player.character_stats.baseStats.lck + player.character_stats.get_pass("lck") + var avoid_chance = luck_stat * 0.02 # 2% per luck point (e.g., 10 luck = 20% avoid) + var avoid_roll = randf() + + if avoid_roll < avoid_chance: + $SfxAvoid.play() + # Player avoided trap damage! + print(player.name, " avoided trap damage! (", avoid_chance * 100, "% chance)") + _show_floating_text("AVOIDED", Color.GREEN) + return + + # Apply trap damage (affected by player's defense) + var final_damage = player.character_stats.calculate_damage(trap_damage, false, false) + + if player.has_method("rpc_take_damage"): + player.rpc_take_damage(trap_damage, global_position) + + print(player.name, " took ", final_damage, " trap damage") diff --git a/src/scripts/trap.gd.uid b/src/scripts/trap.gd.uid new file mode 100644 index 0000000..38b1ecf --- /dev/null +++ b/src/scripts/trap.gd.uid @@ -0,0 +1 @@ +uid://bkp8t4mvdhtqq