fixat mer med traps och arrows och grejjer

This commit is contained in:
2026-01-22 01:03:01 +01:00
parent c0d229ee86
commit eaf86b39fa
20 changed files with 1589 additions and 194 deletions

Binary file not shown.

View File

@@ -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

View File

@@ -67,25 +67,35 @@ grab={
"deadzone": 0.5, "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) "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(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={ throw={
"deadzone": 0.5, "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) "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={ attack={
"deadzone": 0.5, "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) "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(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={ inventory={
"deadzone": 0.2, "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) "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] [physics]
3d/physics_engine="Jolt Physics" 3d/physics_engine="Jolt Physics"

View File

@@ -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://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://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://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"] [sub_resource type="Gradient" id="Gradient_yp18a"]
colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) 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] [node name="SfxArrowFire" type="AudioStreamPlayer2D" parent="." unique_id=1413495156]
stream = ExtResource("3_o8cb2") stream = ExtResource("3_o8cb2")
pitch_scale = 1.61 pitch_scale = 1.61
attenuation = 7.464256
max_polyphony = 4 max_polyphony = 4
panning_strength = 1.01
bus = &"Sfx"
[node name="SfxImpactWall" type="AudioStreamPlayer2D" parent="." unique_id=1132726967] [node name="SfxImpactWall" type="AudioStreamPlayer2D" parent="." unique_id=1132726967]
stream = ExtResource("4_8l43l") stream = ExtResource("4_8l43l")
volume_db = -4.0 volume_db = -4.0
pitch_scale = 1.29 pitch_scale = 1.29
attenuation = 3.4822 attenuation = 3.9999971
max_polyphony = 4 max_polyphony = 4
panning_strength = 1.3 panning_strength = 1.3
bus = &"Sfx"
[node name="SfxImpactSound" type="AudioStreamPlayer2D" parent="." unique_id=463883211] [node name="SfxImpactSound" type="AudioStreamPlayer2D" parent="." unique_id=463883211]
stream = ExtResource("4_ol4b0") stream = ExtResource("4_ol4b0")
volume_db = -4.685 volume_db = -4.685
pitch_scale = 1.47 pitch_scale = 1.47
attenuation = 8.574182
max_polyphony = 4 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="area_entered" from="ArrowArea" to="." method="_on_arrow_area_area_entered"]
[connection signal="body_entered" from="ArrowArea" to="." method="_on_arrow_area_body_entered"] [connection signal="body_entered" from="ArrowArea" to="." method="_on_arrow_area_body_entered"]

View File

@@ -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://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://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://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"] [sub_resource type="Gradient" id="Gradient_wqfne"]
colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1)
@@ -280,6 +284,14 @@ random_pitch = 1.0630184
streams_count = 1 streams_count = 1
stream_0/stream = ExtResource("31_487ah") 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] [node name="Player" type="CharacterBody2D" unique_id=937429705]
collision_mask = 67 collision_mask = 67
motion_mode = 1 motion_mode = 1

133
src/scenes/trap.tscn Normal file
View File

@@ -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"]

View File

@@ -2,22 +2,37 @@ extends CharacterBody2D
var speed = 300 var speed = 300
var direction = Vector2.ZERO 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_stuck = false
var is_collected = false
var stick_timer = 0.0 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 initiated_by: Node2D = null
var player_owner: Node = null # Like sword_projectile var player_owner: Node = null # Like sword_projectile
var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) 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 arrow_area = $ArrowArea # Assuming you have an Area2D node named ArrowArea
@onready var shadow = $Shadow # Assuming you have a Shadow node under the CharacterBody2D @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. # Called when the node enters the scene tree for the first time.
func _ready() -> void: func _ready() -> void:
arrow_area.set_deferred("monitoring", true) arrow_area.set_deferred("monitoring", true)
# Connect area signals # Connect area signals (only if not already connected)
if arrow_area: 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) arrow_area.body_entered.connect(_on_arrow_area_body_entered)
$SfxArrowFire.play() $SfxArrowFire.play()
call_deferred("_initialize_arrow") call_deferred("_initialize_arrow")
@@ -53,22 +68,62 @@ func _initialize_arrow() -> void:
# Apply the scaling to the shadow # Apply the scaling to the shadow
shadow.rotation = -(angle - PI / 2) 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() direction = shoot_direction.normalized()
global_position = start_pos global_position = start_pos
player_owner = owner_player player_owner = owner_player
initiated_by = 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. # Called every frame. 'delta' is the e lapsed time since the previous frame.
func _process(delta: float) -> void: 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: if is_stuck:
# Handle fade out here if it's stuck # Handle fade out here if it's stuck
stick_timer += delta 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 # Start fading out after it sticks
modulate.a = max(0, 1 - (stick_timer - stick_duration) / 1.0) # Fade out over 1 second modulate.a = max(0, 1 - (stick_timer - duration) / 1.0) # Fade out over 1 second
if stick_timer >= stick_duration + 1.0: # Extra second for fade out if stick_timer >= duration + 1.0: # Extra second for fade out
queue_free() # Remove the arrow after fade out queue_free() # Remove the arrow after fade out
move_and_slide() move_and_slide()
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
@@ -86,6 +141,14 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
if is_stuck: if is_stuck:
return 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 # Don't hit the owner
if body == player_owner or body == initiated_by: if body == player_owner or body == initiated_by:
return return
@@ -94,30 +157,36 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
if body in hit_targets: if body in hit_targets:
return 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 # Deal damage to players
if body.is_in_group("player") and body.has_method("rpc_take_damage"): 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() play_impact()
var attacker_pos = player_owner.global_position if player_owner else global_position
var player_peer_id = body.get_multiplayer_authority() # CRITICAL: Only the projectile owner (authority) should deal damage to players
if player_peer_id != 0: if player_owner and player_owner.is_multiplayer_authority():
if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): var attacker_pos = player_owner.global_position if player_owner else global_position
body.rpc_take_damage(20.0, attacker_pos) # TODO: Get actual damage from player 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: else:
body.rpc_take_damage.rpc_id(player_peer_id, 20.0, attacker_pos) body.rpc_take_damage.rpc(20.0, attacker_pos)
else:
body.rpc_take_damage.rpc(20.0, attacker_pos) # Stick to target on ALL clients (both authority and non-authority)
_stick_to_target(body) _stick_to_target(body)
return return
# Deal damage to enemies # Deal damage to enemies
if body.is_in_group("enemy") and body.has_method("rpc_take_damage"): 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 attacker_pos = player_owner.global_position if player_owner else global_position
var damage = 20.0 # TODO: Get actual damage from player var damage = 20.0 # TODO: Get actual damage from player
if player_owner and player_owner.character_stats: 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 var is_miss = hit_roll >= hit_chance
if is_miss: if is_miss:
# MISS - arrow passes through enemy and continues flying!
if body.has_method("_show_damage_number"): if body.has_method("_show_damage_number"):
body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true 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 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() play_impact()
# Deal damage
var enemy_peer_id = body.get_multiplayer_authority() var enemy_peer_id = body.get_multiplayer_authority()
if enemy_peer_id != 0: if enemy_peer_id != 0:
if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): 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) body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, false)
else: else:
body.rpc_take_damage.rpc(damage, attacker_pos, false) 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) _stick_to_target(body)
return return
@@ -175,5 +279,162 @@ func _stick_to_wall():
# Stop the arrow # Stop the arrow
velocity = Vector2.ZERO velocity = Vector2.ZERO
is_stuck = true is_stuck = true
stuck_to_wall = true
stick_timer = 0.0 stick_timer = 0.0
arrow_area.set_deferred("monitoring", false) 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

View File

@@ -73,7 +73,8 @@ var equipment:Dictionary = {
"end": 10, "end": 10,
"wis": 10, "wis": 10,
"cha": 10, "cha": 10,
"lck": 10 "lck": 10,
"per": 10 # Perception - affects trap detection
} }
@export var def: int = 0 @export var def: int = 0
@@ -95,16 +96,20 @@ func get_total_weight() -> float:
for item in inventory: for item in inventory:
if item: if item:
total += item.weight * item.quantity 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(): for slot in equipment.values():
if slot: if slot:
total += slot.weight if slot.can_have_multiple_of:
total += slot.weight * slot.quantity
else:
total += slot.weight
return total return total
# Calculate carrying capacity based on STR # Calculate carrying capacity based on STR
func get_carrying_capacity() -> float: func get_carrying_capacity() -> float:
# Base capacity: 20 + (STR * 5) # Base capacity: 7 + (STR * 1.75) - reduced to 35% of original
return 20.0 + (baseStats.str * 5.0) # Example: 10 STR = 24.5, 20 STR = 42
return 7.0 + (baseStats.str * 1.75)
# Check if over-encumbered # Check if over-encumbered
func is_over_encumbered() -> bool: func is_over_encumbered() -> bool:
@@ -245,7 +250,7 @@ func level_up() -> void:
var num_stats_to_increase = randi_range(2, 3) var num_stats_to_increase = randi_range(2, 3)
# All available stats (excluding cha for now as per user request) # 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 # Shuffle and pick random stats
var stats_to_increase = [] var stats_to_increase = []
@@ -550,27 +555,37 @@ func add_item(iItem:Item):
func unequip_item(iItem:Item, updateChar:bool = true): func unequip_item(iItem:Item, updateChar:bool = true):
if iItem.equipment_type == Item.EquipmentType.NONE: if iItem.equipment_type == Item.EquipmentType.NONE:
return 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: match iItem.equipment_type:
Item.EquipmentType.MAINHAND: Item.EquipmentType.MAINHAND:
equipment["mainhand"] = null equipment["mainhand"] = null
pass
Item.EquipmentType.OFFHAND: Item.EquipmentType.OFFHAND:
equipment["offhand"] = null equipment["offhand"] = null
pass
Item.EquipmentType.HEADGEAR: Item.EquipmentType.HEADGEAR:
equipment["headgear"] = null equipment["headgear"] = null
pass
Item.EquipmentType.ARMOUR: Item.EquipmentType.ARMOUR:
equipment["armour"] = null equipment["armour"] = null
pass
Item.EquipmentType.BOOTS: Item.EquipmentType.BOOTS:
equipment["boots"] = null equipment["boots"] = null
pass
Item.EquipmentType.ACCESSORY: Item.EquipmentType.ACCESSORY:
equipment["accessory"] = null equipment["accessory"] = null
pass
pass
if updateChar: if updateChar:
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
@@ -587,55 +602,74 @@ func equip_item(iItem:Item, insert_index: int = -1):
var old_item = null var old_item = null
var item_index = self.inventory.find(iItem) 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: match iItem.equipment_type:
Item.EquipmentType.MAINHAND: Item.EquipmentType.MAINHAND:
if equipment["mainhand"] != null: equipped_item = equipment["mainhand"]
old_item = equipment["mainhand"]
equipment["mainhand"] = iItem
pass
pass
Item.EquipmentType.OFFHAND: Item.EquipmentType.OFFHAND:
if equipment["offhand"] != null: equipped_item = equipment["offhand"]
old_item = equipment["offhand"]
equipment["offhand"] = iItem
pass
pass
Item.EquipmentType.HEADGEAR: Item.EquipmentType.HEADGEAR:
if equipment["headgear"] != null: equipped_item = equipment["headgear"]
old_item = equipment["headgear"]
equipment["headgear"] = iItem
pass
pass
Item.EquipmentType.ARMOUR: Item.EquipmentType.ARMOUR:
if equipment["armour"] != null: equipped_item = equipment["armour"]
old_item = equipment["armour"]
equipment["armour"] = iItem
pass
pass
Item.EquipmentType.BOOTS: Item.EquipmentType.BOOTS:
if equipment["boots"] != null: equipped_item = equipment["boots"]
old_item = equipment["boots"]
equipment["boots"] = iItem
pass
pass
Item.EquipmentType.ACCESSORY: Item.EquipmentType.ACCESSORY:
if equipment["accessory"] != null: equipped_item = equipment["accessory"]
old_item = equipment["accessory"]
equipment["accessory"] = iItem
pass
pass
# Remove the item being equipped from inventory first # Check if items can stack (same item name, both stackable)
if item_index >= 0: if equipped_item and iItem.can_have_multiple_of and equipped_item.can_have_multiple_of:
self.inventory.remove_at(item_index) 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 can_stack:
if old_item != null: # Stack quantities together
if insert_index >= 0 and insert_index <= self.inventory.size(): equipped_item.quantity += iItem.quantity
self.inventory.insert(insert_index, old_item) # Remove the item from inventory since we merged it
else: if item_index >= 0:
self.inventory.push_back(old_item) 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) emit_signal("character_changed", self)
pass pass

View File

@@ -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) 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) 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: 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 # 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, "torches": all_torches,
"enemies": all_enemies, "enemies": all_enemies,
"interactable_objects": all_interactable_objects, "interactable_objects": all_interactable_objects,
"traps": all_traps,
"stairs": stairs_data, "stairs": stairs_data,
"blocking_doors": blocking_doors, "blocking_doors": blocking_doors,
"grid": grid, "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 return "Left" # Door is left of puzzle room center - door is on left wall
else: else:
return "Right" # Door is right of puzzle room center - door is on right wall 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

View File

@@ -1754,6 +1754,7 @@ func _init_mouse_cursor():
cursor_sprite.hframes = 2 cursor_sprite.hframes = 2
cursor_sprite.vframes = 1 cursor_sprite.vframes = 1
cursor_sprite.frame = 0 # Frame 0 = free movement cursor_sprite.frame = 0 # Frame 0 = free movement
cursor_sprite.modulate.a = 0.75 # 75% opacity
cursor_layer.add_child(cursor_sprite) cursor_layer.add_child(cursor_sprite)
# Create grid-locked cursor sprite (frame 1) # Create grid-locked cursor sprite (frame 1)
@@ -1763,7 +1764,7 @@ func _init_mouse_cursor():
grid_cursor_sprite.hframes = 2 grid_cursor_sprite.hframes = 2
grid_cursor_sprite.vframes = 1 grid_cursor_sprite.vframes = 1
grid_cursor_sprite.frame = 1 # Frame 1 = grid-locked 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) cursor_layer.add_child(grid_cursor_sprite)
# Hide system cursor # Hide system cursor
@@ -1826,20 +1827,30 @@ func _update_mouse_cursor(delta: float):
var base_color = Color(1.0, 1.0, 1.0) var base_color = Color(1.0, 1.0, 1.0)
var pulse_color = Color(1.5, 1.2, 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 = 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) # 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: if local_players.size() > 0:
var player = local_players[0] # Use first local player var player = local_players[0] # Use first local player
if player and is_instance_valid(player) and player.is_local_player: if player and is_instance_valid(player) and player.is_local_player:
var player_pos = player.global_position if mouse_in_window:
# Use grid-locked position if available, otherwise use free mouse position # Mouse is in window - use mouse for direction control
var target_world_pos = grid_locked_world_pos if show_grid_cursor else world_pos var player_pos = player.global_position
var mouse_direction = (target_world_pos - player_pos).normalized() # 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
# Only update facing if mouse is far enough from player var mouse_direction = (target_world_pos - player_pos).normalized()
if mouse_direction.length() > 0.1:
player._update_facing_from_mouse(mouse_direction) # 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(): func _init_fog_of_war():
if dungeon_data.is_empty() or not dungeon_data.has("map_size"): 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_interactable_objects() _spawn_interactable_objects()
# Spawn traps
_spawn_traps()
# Spawn blocking doors # Spawn blocking doors
_spawn_blocking_doors() _spawn_blocking_doors()
@@ -3310,6 +3324,7 @@ func _pack_dungeon_blob():
"torches": dungeon_data.get("torches", []), "torches": dungeon_data.get("torches", []),
"enemies": dungeon_data.get("enemies", []), "enemies": dungeon_data.get("enemies", []),
"interactable_objects": dungeon_data.get("interactable_objects", []), "interactable_objects": dungeon_data.get("interactable_objects", []),
"traps": dungeon_data.get("traps", []),
"stairs": dungeon_data.get("stairs", {}), "stairs": dungeon_data.get("stairs", {}),
"blocking_doors": dungeon_data.get("blocking_doors", []) "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 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 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 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") var trap_count = full_dungeon_data.traps.size() if full_dungeon_data.traps is Array else 0
LogManager.log("GameWorld: Packing dungeon blob - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count), LogManager.CATEGORY_DUNGEON) 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) # 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 # 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 previous level on client
_clear_level() _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 # Wait for old entities to be fully freed before spawning new ones
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame # Wait an extra frame to ensure cleanup is complete 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...") print("GameWorld: Client - Dungeon data assembled! Rendering...")
LogManager.log("GameWorld: Client assembled dungeon from chunks for level " + str(current_level), LogManager.CATEGORY_DUNGEON) 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
_fix_player_appearance_after_dungeon_sync() _fix_player_appearance_after_dungeon_sync()
@@ -4348,6 +4376,62 @@ func _spawn_enemies():
else: else:
print("GameWorld: [CLEANUP] No defeated enemies found in scene to remove (all were properly skipped during spawn)") 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(): func _spawn_interactable_objects():
# Spawn interactable objects from dungeon data # Spawn interactable objects from dungeon data
if dungeon_data.is_empty() or not dungeon_data.has("interactable_objects"): 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"): if dungeon_data.is_empty() or not dungeon_data.has("start_room"):
return 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 var start_room = dungeon_data.start_room
_update_spawn_points(start_room) _update_spawn_points(start_room)

View File

@@ -18,6 +18,7 @@ var held_by_player = null
var is_frozen: bool = false var is_frozen: bool = false
var thrown_by_player = null # Track who threw this box var thrown_by_player = null # Track who threw this box
var is_broken: bool = false var is_broken: bool = false
var has_dealt_damage: bool = false # Track if this thrown object has already damaged something
# Physics for thrown objects # Physics for thrown objects
var throw_velocity: Vector2 = Vector2.ZERO var throw_velocity: Vector2 = Vector2.ZERO
@@ -129,6 +130,7 @@ func _land():
is_being_held = false # Make sure it can be grabbed again is_being_held = false # Make sure it can be grabbed again
held_by_player = null held_by_player = null
thrown_by_player = null # Clear who threw it thrown_by_player = null # Clear who threw it
has_dealt_damage = false # Reset damage flag for next throw
# Re-enable collision when landing # Re-enable collision when landing
set_collision_layer_value(2, true) set_collision_layer_value(2, true)
@@ -158,15 +160,20 @@ func _land():
func _handle_air_collision(): func _handle_air_collision():
# Handle collision while airborne # Handle collision while airborne
# CRITICAL: Only allow ONE damage event per throw
if has_dealt_damage:
return
for i in get_slide_collision_count(): for i in get_slide_collision_count():
var collision = get_slide_collision(i) var collision = get_slide_collision(i)
var collider = collision.get_collider() var collider = collision.get_collider()
# Pot special case: break on wall collision # Break on wall collision (pots and boxes)
if object_type == "Pot" and _is_wall_collider(collider): if (object_type == "Pot" or object_type == "Box") and _is_wall_collider(collider):
# Only process on server to prevent duplicates # Only process on server to prevent duplicates
if not multiplayer.is_server(): if not multiplayer.is_server():
continue continue
has_dealt_damage = true # Mark as dealt damage (wall hit counts)
if is_destroyable: if is_destroyable:
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
var game_world = get_tree().get_first_node_in_group("game_world") 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(): if not multiplayer.is_server():
continue 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"): 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 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) var base_damage = 10.0 if object_type == "Pot" else 15.0
print(name, " hit enemy ", collider.name, "!") 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) # Box breaks (only if destroyable)
if is_destroyable: if is_destroyable:
@@ -209,6 +220,8 @@ func _handle_air_collision():
if not multiplayer.is_server(): if not multiplayer.is_server():
continue 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) # Hit a player! Break locally and sync to others (only if destroyable)
if is_destroyable: if is_destroyable:
# Sync break to OTHER clients via RPC BEFORE breaking locally # Sync break to OTHER clients via RPC BEFORE breaking locally
@@ -220,23 +233,25 @@ func _handle_air_collision():
_break_into_pieces() _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 # Pass the thrower's position for accurate direction
if collider.has_method("rpc_take_damage"): 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 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() var player_peer_id = collider.get_multiplayer_authority()
if player_peer_id != 0: if player_peer_id != 0:
# If target peer is the same as server (us), call directly # If target peer is the same as server (us), call directly
# rpc_id() might not execute locally when called to same peer # rpc_id() might not execute locally when called to same peer
if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id():
# Call directly on the same peer # Call directly on the same peer
collider.rpc_take_damage(10.0, attacker_pos) collider.rpc_take_damage(base_damage, attacker_pos)
else: else:
# Send RPC to remote peer # 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: else:
# Fallback: broadcast if we can't get peer_id # 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, "!") print(name, " hit player ", collider.name, "!")
return return
@@ -245,6 +260,8 @@ func _handle_air_collision():
if not multiplayer.is_server(): if not multiplayer.is_server():
continue continue
has_dealt_damage = true # Mark as dealt damage - can't damage anything else now
# Hit another box! Break both locally (only if destroyable) # Hit another box! Break both locally (only if destroyable)
if is_destroyable: if is_destroyable:
# Sync break to OTHER clients via RPC BEFORE breaking locally # Sync break to OTHER clients via RPC BEFORE breaking locally
@@ -416,6 +433,7 @@ func on_grabbed(by_player):
is_being_held = true is_being_held = true
held_by_player = by_player held_by_player = by_player
has_dealt_damage = false # Reset damage flag when picked up
print(name, " grabbed by ", by_player.name) print(name, " grabbed by ", by_player.name)
func on_lifted(by_player): func on_lifted(by_player):
@@ -434,6 +452,7 @@ func on_released(by_player):
position_z = 0.0 position_z = 0.0
velocity_z = 0.0 velocity_z = 0.0
throw_velocity = Vector2.ZERO throw_velocity = Vector2.ZERO
has_dealt_damage = false # Reset damage flag when released
# Re-enable collision (in case it was disabled) # Re-enable collision (in case it was disabled)
set_collision_layer_value(2, true) set_collision_layer_value(2, true)
@@ -459,6 +478,7 @@ func on_thrown(by_player, force: Vector2):
held_by_player = null held_by_player = null
thrown_by_player = by_player # Remember who threw this thrown_by_player = by_player # Remember who threw this
is_frozen = false 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 # Set throw velocity (affected by weight) - increased for longer arc
throw_velocity = force / weight throw_velocity = force / weight
@@ -495,7 +515,7 @@ func setup_pot():
can_be_pushed = true can_be_pushed = true
is_destroyable = true is_destroyable = true
is_liftable = 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] var pot_frames = [1, 2, 3, 20, 21, 22, 58]
if sprite: if sprite:
@@ -554,7 +574,7 @@ func setup_box():
can_be_pushed = true can_be_pushed = true
is_destroyable = true is_destroyable = true
is_liftable = true is_liftable = true
weight = 1.0 weight = 1.5 # Boxes are heavier than pots
var box_frames = [7, 26] var box_frames = [7, 26]
if sprite: if sprite:

View File

@@ -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_food: AudioStreamPlayer2D = $SfxFood
@onready var sfx_armour: AudioStreamPlayer2D = $SfxArmour @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 # Store button/item mappings for selection highlighting
var inventory_buttons: Dictionary = {} # item -> button var inventory_buttons: Dictionary = {} # item -> button
var equipment_buttons: Dictionary = {} # slot_name -> button var equipment_buttons: Dictionary = {} # slot_name -> button
@@ -75,6 +80,9 @@ func _ready():
# Create equipment slot buttons (dynamically) # Create equipment slot buttons (dynamically)
_create_equipment_slots() _create_equipment_slots()
# Create weight progress bar
_create_weight_ui()
# Setup selection rectangle (already in scene, just configure it) # Setup selection rectangle (already in scene, just configure it)
_setup_selection_rectangle() _setup_selection_rectangle()
@@ -185,9 +193,26 @@ func _update_stats():
str(char_stats.defense) + "\n" + \ str(char_stats.defense) + "\n" + \
str(char_stats.move_speed) + "\n" + \ str(char_stats.move_speed) + "\n" + \
str(char_stats.attack_speed) + "\n" + \ str(char_stats.attack_speed) + "\n" + \
str(char_stats.sight) + "\n" + \ str(char_stats.sight)
str(char_stats.spell_amp) + "\n" + \
str(char_stats.crit_chance) + "%" # 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(): func _create_equipment_slots():
# Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory # Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory
@@ -244,6 +269,46 @@ func _create_equipment_slots():
equipment_slots[slot_name] = button equipment_slots[slot_name] = button
equipment_buttons[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: func _has_equipment_in_slot(slot_name: String) -> bool:
# Check if there's an item equipped in this slot # Check if there's an item equipped in this slot
if not local_player or not local_player.character_stats: 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: if equipped_item.can_have_multiple_of and equipped_item.quantity > 1:
var quantity_label = Label.new() var quantity_label = Label.new()
quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
quantity_label.size = Vector2(24, 24) quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
quantity_label.custom_minimum_size = Vector2(0, 0) quantity_label.size = Vector2(36, 36)
quantity_label.position = Vector2(10, 2) quantity_label.custom_minimum_size = Vector2(36, 36)
quantity_label.position = Vector2(0, 0)
quantity_label.text = str(equipped_item.quantity) quantity_label.text = str(equipped_item.quantity)
if quantity_font: # Use dmg_numbers.png font (same as inventory items)
quantity_label.add_theme_font_override("font", quantity_font) var dmg_font_resource = load("res://assets/fonts/dmg_numbers.png")
quantity_label.add_theme_font_size_override("font_size", 8) if dmg_font_resource:
quantity_label.scale = Vector2(0.5, 0.5) 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) button.add_child(quantity_label)
# Update inventory grid - clear existing HBoxContainers # Update inventory grid - clear existing HBoxContainers
@@ -727,6 +798,13 @@ func _format_item_info(item: Item) -> String:
text += ", ".join(stat_lines) text += ", ".join(stat_lines)
text += "\n\n" 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 # Controls
if item.item_type == Item.ItemType.Equippable: if item.item_type == Item.ItemType.Equippable:
if selected_type == "equipment": if selected_type == "equipment":
@@ -903,6 +981,7 @@ func _on_inventory_item_pressed(item: Item):
_update_selection_highlight() _update_selection_highlight()
_update_selection_rectangle() _update_selection_rectangle()
_update_info_panel() # Show item description on single-click
func _on_inventory_item_gui_input(event: InputEvent, item: Item): func _on_inventory_item_gui_input(event: InputEvent, item: Item):
# Handle double-click to equip/consume and right-click to drop # Handle double-click to equip/consume and right-click to drop

View File

@@ -55,7 +55,7 @@ var weapon_type: WeaponType = WeaponType.NONE
var two_handed:bool = false var two_handed:bool = false
var quantity = 1 var quantity = 1
var can_have_multiple_of:bool = false 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(): func save():
var json = { var json = {

View File

@@ -76,7 +76,8 @@ static func _load_all_items():
"modifiers": {"def": 3}, "modifiers": {"def": 3},
"buy_cost": 80, "buy_cost": 80,
"sell_worth": 24, "sell_worth": 24,
"rarity": ItemRarity.COMMON "rarity": ItemRarity.COMMON,
"weight": 4.0 # Light armour
}) })
_register_item("plate", { _register_item("plate", {
@@ -90,7 +91,8 @@ static func _load_all_items():
"modifiers": {"def": 5, "end": 1}, "modifiers": {"def": 5, "end": 1},
"buy_cost": 150, "buy_cost": 150,
"sell_worth": 45, "sell_worth": 45,
"rarity": ItemRarity.UNCOMMON "rarity": ItemRarity.UNCOMMON,
"weight": 10.0 # Heavy armour!
}) })
_register_item("full_mail", { _register_item("full_mail", {
@@ -326,7 +328,8 @@ static func _load_all_items():
"modifiers": {"def": 2}, "modifiers": {"def": 2},
"buy_cost": 40, "buy_cost": 40,
"sell_worth": 12, "sell_worth": 12,
"rarity": ItemRarity.COMMON "rarity": ItemRarity.COMMON,
"weight": 1.5 # Boots are light
}) })
_register_item("sturdy_boots", { _register_item("sturdy_boots", {
@@ -450,7 +453,8 @@ static func _load_all_items():
"modifiers": {"dmg": 5}, "modifiers": {"dmg": 5},
"buy_cost": 100, "buy_cost": 100,
"sell_worth": 30, "sell_worth": 30,
"rarity": ItemRarity.COMMON "rarity": ItemRarity.COMMON,
"weight": 3.5
}) })
_register_item("sword_of_blaze", { _register_item("sword_of_blaze", {
@@ -935,7 +939,8 @@ static func _load_all_items():
"modifiers": {"dmg": 4, "dex": 2}, "modifiers": {"dmg": 4, "dex": 2},
"buy_cost": 100, "buy_cost": 100,
"sell_worth": 30, "sell_worth": 30,
"rarity": ItemRarity.COMMON "rarity": ItemRarity.COMMON,
"weight": 2.5 # Bows are moderate weight
}) })
_register_item("dark_bow", { _register_item("dark_bow", {
@@ -998,12 +1003,14 @@ static func _load_all_items():
"equipment_type": Item.EquipmentType.OFFHAND, "equipment_type": Item.EquipmentType.OFFHAND,
"weapon_type": Item.WeaponType.AMMUNITION, "weapon_type": Item.WeaponType.AMMUNITION,
"spriteFrame": 7 * 20 + 11, # 11,7 "spriteFrame": 7 * 20 + 11, # 11,7
"quantity": 13, "quantity": 15, # Increased from 13 to 15
"can_have_multiple_of": true, "can_have_multiple_of": true,
"modifiers": {"dmg": 2}, "modifiers": {"dmg": 2},
"buy_cost": 20, "buy_cost": 20,
"sell_worth": 6, "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) # CONSUMABLE FOOD ITEMS (row 7)
@@ -1258,22 +1265,37 @@ static func create_item(item_id: String) -> Item:
return 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: static func get_random_item_by_rarity(rarity: ItemRarity) -> Item:
_initialize() _initialize()
var candidates = [] var candidates = []
var weights = []
var total_weight = 0.0
for item_id in item_definitions.keys(): for item_id in item_definitions.keys():
var item_data = item_definitions[item_id] var item_data = item_definitions[item_id]
if item_data.has("rarity") and item_data["rarity"] == rarity: if item_data.has("rarity") and item_data["rarity"] == rarity:
candidates.append(item_id) 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(): if candidates.is_empty():
# Fallback to common items # Fallback to common items
return get_random_item_by_rarity(ItemRarity.COMMON) return get_random_item_by_rarity(ItemRarity.COMMON)
var random_item_id = candidates[randi() % candidates.size()] # Weighted random selection based on drop_chance
return create_item(random_item_id) 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) # Get a random item (weighted by rarity)
static func get_random_item() -> Item: static func get_random_item() -> Item:

View File

@@ -54,6 +54,9 @@ var item: Item = null # Item instance (for LootType.ITEM)
@onready var sfx_banana_collect = $SfxBananaCollect @onready var sfx_banana_collect = $SfxBananaCollect
@onready var sfx_key_collect = $SfxKeyCollect @onready var sfx_key_collect = $SfxKeyCollect
# Quantity badge for items with quantity > 1
var quantity_badge: Label = null
func _ready(): func _ready():
add_to_group("loot") add_to_group("loot")
@@ -150,6 +153,10 @@ func _setup_sprite():
sprite.vframes = item.spriteFrames.y if item.spriteFrames.y > 0 else 14 sprite.vframes = item.spriteFrames.y if item.spriteFrames.y > 0 else 14
sprite.frame = item.spriteFrame sprite.frame = item.spriteFrame
print("Loot: Set up item sprite for ", item.item_name, " frame=", sprite.frame) 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: else:
print("Loot: ERROR - Could not load texture from spritePath: ", item.spritePath) print("Loot: ERROR - Could not load texture from spritePath: ", item.spritePath)
else: else:
@@ -179,6 +186,18 @@ func _setup_collision_shape():
collision_shape.shape = circle_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): func _physics_process(delta):
# Stop all physics processing if collected # Stop all physics processing if collected
if collected: if collected:

View File

@@ -8,7 +8,7 @@ var appearance_rng: RandomNumberGenerator # Deterministic RNG for appearance/sta
@export var move_speed: float = 80.0 @export var move_speed: float = 80.0
@export var grab_range: float = 20.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) @export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider)
# Network identity # 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 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_right_pressed: bool = false # Track previous mouse right button state
var was_mouse_left_pressed: bool = false # Track previous mouse left 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 # Interaction
var held_object = null var held_object = null
@@ -63,6 +64,9 @@ var knockback_duration: float = 0.3 # How long knockback lasts
var can_attack: bool = true var can_attack: bool = true
var attack_cooldown: float = 0.0 # No cooldown - instant attacks! var attack_cooldown: float = 0.0 # No cooldown - instant attacks!
var is_attacking: bool = false 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_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 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 var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile
@@ -167,6 +171,12 @@ const ANIMATIONS = {
"loop": false, "loop": false,
"nextAnimation": "IDLE" "nextAnimation": "IDLE"
}, },
"BOW_STRING": {
"frames": [9],
"frameDurations": [30],
"loop": true,
"nextAnimation": null,
},
"BOW": { "BOW": {
"frames": [9, 10, 11, 12], "frames": [9, 10, 11, 12],
"frameDurations": [80, 110, 110, 80], "frameDurations": [80, 110, 110, 80],
@@ -244,6 +254,7 @@ const ANIMATIONS = {
var current_animation = "IDLE" var current_animation = "IDLE"
var current_frame = 0 var current_frame = 0
var current_direction = Direction.DOWN 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 var time_since_last_frame = 0.0
func _ready(): func _ready():
@@ -253,6 +264,9 @@ func _ready():
# Set respawn point to starting position # Set respawn point to starting position
respawn_point = global_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 # Set up input device based on local player index
if is_local_player: if is_local_player:
if local_player_index == 0: if local_player_index == 0:
@@ -268,7 +282,10 @@ func _ready():
_duplicate_sprite_materials() _duplicate_sprite_materials()
# Set up player appearance (randomized based on stats) # 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 # 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.wis = appearance_rng.randi_range(8, 12)
character_stats.baseStats.cha = 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.lck = appearance_rng.randi_range(8, 12)
character_stats.baseStats.per = appearance_rng.randi_range(8, 12)
# Apply race-based stat modifiers # Apply race-based stat modifiers
match character_stats.race: match character_stats.race:
"Dwarf": "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.str += 3
character_stats.baseStats.int -= 2 character_stats.baseStats.int -= 2
character_stats.baseStats.wis -= 2 character_stats.baseStats.wis -= 2
character_stats.baseStats.lck -= 2 character_stats.baseStats.lck -= 2
character_stats.baseStats.per += 1
"Elf": "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.dex += 3
character_stats.baseStats.int -= 2 character_stats.baseStats.int -= 2
character_stats.baseStats.lck += 2 character_stats.baseStats.lck += 2
character_stats.baseStats.per += 4 # Highest perception for trap detection
"Human": "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.str -= 2
character_stats.baseStats.dex -= 2 character_stats.baseStats.dex -= 2
character_stats.baseStats.int += 3 character_stats.baseStats.int += 3
character_stats.baseStats.wis += 3 character_stats.baseStats.wis += 3
character_stats.baseStats.lck -= 2 character_stats.baseStats.lck -= 2
character_stats.baseStats.per -= 1
# Stats randomized (verbose logging removed) # 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 AFTER race is set (race affects stat modifiers)
_randomize_stats() _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) # Randomize skin (human only for players)
# Weighted random: Human1 has highest chance, Human7 has lowest chance # 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) # 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 sprite layers
_apply_appearance_to_sprites() _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(): func _apply_appearance_to_sprites():
# Apply character_stats appearance to sprite layers # Apply character_stats appearance to sprite layers
@@ -998,6 +1035,9 @@ func _on_character_changed(_char: CharacterStats):
else: else:
equipment_data[slot_name] = null equipment_data[slot_name] = null
_rpc_to_ready_peers("_sync_equipment", [equipment_data]) _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) # 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 # This ensures joiners see items they pick up and equipment changes
@@ -1221,8 +1261,15 @@ func _update_animation(delta):
sprite_addons.frame = frame_index sprite_addons.frame = frame_index
if sprite_headgear: if sprite_headgear:
sprite_headgear.frame = frame_index sprite_headgear.frame = frame_index
# Update weapon sprite - use BOW_STRING animation if charging bow
if sprite_weapon: 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: func _get_direction_from_vector(vec: Vector2) -> int:
if vec.length() < 0.1: if vec.length() < 0.1:
@@ -1263,6 +1310,13 @@ func _update_facing_from_mouse(mouse_direction: Vector2):
if is_pushing: if is_pushing:
return 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 var new_direction = _get_direction_from_vector(mouse_direction) as Direction
# Update direction and cone light rotation if changed # Update direction and cone light rotation if changed
@@ -1702,19 +1756,25 @@ func _handle_input():
if input_vector.length() > 0.1: if input_vector.length() > 0.1:
last_movement_direction = input_vector.normalized() last_movement_direction = input_vector.normalized()
# Update facing direction (except when pushing - locked direction) # Update full 360-degree facing direction for attacks (gamepad/keyboard input)
# Note: Mouse control will override this if mouse is being used # Only update if mouse control is not active (i.e., mouse is outside window or using gamepad)
var new_direction = current_direction if not is_pushing and (not mouse_control_active or input_device != -1):
if not is_pushing: facing_direction_vector = input_vector.normalized()
new_direction = _get_direction_from_vector(input_vector) as Direction
else:
# Keep locked direction when pushing
new_direction = push_direction_locked as Direction
# Update direction and cone light rotation if changed # Update facing direction for animations (except when pushing - locked direction)
if new_direction != current_direction: # Only update from movement input if mouse control is not active or using gamepad
current_direction = new_direction if not is_pushing and (not mouse_control_active or input_device != -1):
_update_cone_light_rotation() 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 # Set animation based on state
if is_lifting: if is_lifting:
@@ -1817,6 +1877,36 @@ func _handle_interactions():
else: else:
grab_just_released = false 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 # Track how long grab button is held
if grab_button_down: if grab_button_down:
grab_button_pressed_time += get_process_delta_time() grab_button_pressed_time += get_process_delta_time()
@@ -1839,14 +1929,8 @@ func _handle_interactions():
grab_start_time = Time.get_ticks_msec() / 1000.0 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) 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: elif is_lifting:
# Already lifting - check if moving to throw, or just put down # Already lifting - always place down (throwing is now only via attack button)
var is_moving = velocity.length() > 10.0 _place_down_object()
if is_moving:
# Moving + tap E = throw
_throw_object()
else:
# Not moving + tap E = put down
_place_down_object()
# Handle grab button release # Handle grab button release
# CRITICAL: Don't process release if: # CRITICAL: Don't process release if:
@@ -1926,27 +2010,108 @@ func _handle_interactions():
_start_pushing() _start_pushing()
# Lift will only happen on release if it was a quick tap # 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_pressed = false
var attack_just_released = false
if input_device == -1: if input_device == -1:
# Keyboard or Mouse # Keyboard or Mouse
var mouse_left_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) attack_pressed = Input.is_action_pressed("attack")
attack_just_pressed = Input.is_action_just_pressed("attack") or (mouse_left_pressed and not was_mouse_left_pressed) attack_just_pressed = Input.is_action_just_pressed("attack")
was_mouse_left_pressed = mouse_left_pressed attack_just_released = Input.is_action_just_released("attack")
else: else:
# Gamepad (X button) # 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: # Check if player has bow + arrows equipped
if is_lifting: var has_bow_and_arrows = false
# Attack while lifting -> throw immediately (no movement required) var equipped_weapon = null
_force_throw_held_object(last_movement_direction) var equipped_arrows = null
elif not is_pushing: 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() _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 # 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 # 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(): func _try_grab():
if not grab_area: if not grab_area:
return return
@@ -2102,8 +2267,9 @@ func _start_pushing():
# Snap to one of 8 directions # Snap to one of 8 directions
push_axis = _snap_to_8_directions(initial_direction) 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) 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 # Re-enable collision with walls (layer 7) for pushing, but keep collision with players/objects disabled
if _is_box(held_object): if _is_box(held_object):
@@ -2131,6 +2297,51 @@ func _force_drop_held_object():
# Just release # Just release
_stop_pushing() _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(): func _stop_pushing():
if not held_object: if not held_object:
return return
@@ -2158,10 +2369,12 @@ func _stop_pushing():
released_obj.set_collision_layer_value(2, true) released_obj.set_collision_layer_value(2, true)
released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(1, true)
released_obj.set_collision_mask_value(2, 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): elif _is_player(released_obj):
# Players: back on layer 1 # Players: back on layer 1
released_obj.set_collision_layer_value(1, true) released_obj.set_collision_layer_value(1, true)
released_obj.set_collision_mask_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"): if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"):
released_obj.set_being_held(false) released_obj.set_being_held(false)
@@ -2183,6 +2396,14 @@ func _stop_pushing():
initial_player_position = Vector2.ZERO initial_player_position = Vector2.ZERO
print("Stopped pushing") 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(): func _throw_object():
if not held_object or not is_lifting: if not held_object or not is_lifting:
return return
@@ -2201,6 +2422,9 @@ func _throw_object():
_place_down_object() _place_down_object()
return return
# Calculate throw force based on STR
var throw_force = _get_throw_force()
# Position object at player's position before throwing # Position object at player's position before throwing
var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front 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: if throw_direction.length() < 0.1:
throw_direction = Vector2.RIGHT throw_direction = Vector2.RIGHT
# Calculate throw force based on STR
var throw_force = _get_throw_force()
# Position object at player's position before throwing # Position object at player's position before throwing
var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front 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_layer_value(2, true)
thrown_obj.set_collision_mask_value(1, true) thrown_obj.set_collision_mask_value(1, true)
thrown_obj.set_collision_mask_value(2, 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): elif _is_player(thrown_obj):
# Player: set position and physics first # Player: set position and physics first
thrown_obj.global_position = throw_start_pos 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): if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_layer_value(1, true)
thrown_obj.set_collision_mask_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"): if thrown_obj.has_method("on_thrown"):
thrown_obj.on_thrown(self, throw_direction * throw_force) thrown_obj.on_thrown(self, throw_direction * throw_force)
@@ -2373,8 +2602,8 @@ func _place_down_object():
if not held_object: if not held_object:
return return
# Place object in front of player based on last movement direction # Place object in front of player based on facing direction (mouse or movement)
var place_pos = _find_closest_place_pos(last_movement_direction, held_object) var place_pos = _find_closest_place_pos(facing_direction_vector, held_object)
var placed_obj = 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), ")") 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_layer_value(2, true)
placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(1, true)
placed_obj.set_collision_mask_value(2, 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 # Stop movement and reset all state
if "throw_velocity" in placed_obj: if "throw_velocity" in placed_obj:
@@ -2417,6 +2647,7 @@ func _place_down_object():
# Player: back on layer 1 # Player: back on layer 1
placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_layer_value(1, true)
placed_obj.set_collision_mask_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.global_position = place_pos
placed_obj.velocity = Vector2.ZERO placed_obj.velocity = Vector2.ZERO
if placed_obj.has_method("set_being_held"): if placed_obj.has_method("set_being_held"):
@@ -2460,25 +2691,8 @@ func _perform_attack():
else: else:
_set_animation("SWORD") _set_animation("SWORD")
# Calculate attack direction based on player's facing direction # Use full 360-degree facing direction for attack
var attack_direction = Vector2.ZERO var attack_direction = facing_direction_vector.normalized()
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()
# Delay before spawning projectile # Delay before spawning projectile
await get_tree().create_timer(0.15).timeout await get_tree().create_timer(0.15).timeout
@@ -2519,7 +2733,10 @@ func _perform_attack():
if attack_arrow_scene: if attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate() var arrow_projectile = attack_arrow_scene.instantiate()
get_parent().add_child(arrow_projectile) 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 # Play bow shoot sound
if has_node("SfxBowShoot"): if has_node("SfxBowShoot"):
$SfxBowShoot.play() $SfxBowShoot.play()
@@ -2571,7 +2788,7 @@ func _perform_attack():
# Sync attack over network # Sync attack over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): 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) # Reset attack cooldown (instant if cooldown is 0)
if attack_cooldown > 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 shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3
@rpc("any_peer", "reliable") @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 # Sync attack to other clients
# Check if node still exists and is valid before processing # Check if node still exists and is valid before processing
if not is_inside_tree() or not is_instance_valid(self): 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: if attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate() var arrow_projectile = attack_arrow_scene.instantiate()
get_parent().add_child(arrow_projectile) get_parent().add_child(arrow_projectile)
arrow_projectile.shoot(attack_dir, global_position, self) # Use charge percentage from sync (matches local player's arrow)
print(name, " performed synced bow attack with arrow!") arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage)
print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)")
else: else:
# No arrows - just play animation, no projectile (matches host behavior) # No arrows - just play animation, no projectile (matches host behavior)
print(name, " performed synced bow attack without arrows (no projectile)") 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 projectile.global_position = global_position + spawn_offset
print(name, " performed synced attack!") 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") @rpc("any_peer", "reliable")
func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_name: String): 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) # 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_layer_value(2, true)
obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true) obj.set_collision_mask_value(2, true)
obj.set_collision_mask_value(7, true) # Re-enable wall collision!
if "is_frozen" in obj: if "is_frozen" in obj:
obj.is_frozen = false obj.is_frozen = false
# CRITICAL: Clear is_being_held so object can be grabbed again and switches can detect it # 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): elif _is_player(obj):
obj.set_collision_layer_value(1, true) obj.set_collision_layer_value(1, true)
obj.set_collision_mask_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"): if obj.has_method("set_being_held"):
obj.set_being_held(false) 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_layer_value(2, true)
obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true) obj.set_collision_mask_value(2, true)
obj.set_collision_mask_value(7, true) # Re-enable wall collision!
# Reset all state # Reset all state
if "throw_velocity" in obj: if "throw_velocity" in obj:
@@ -3196,6 +3431,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2):
elif _is_player(obj): elif _is_player(obj):
obj.set_collision_layer_value(1, true) obj.set_collision_layer_value(1, true)
obj.set_collision_mask_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 obj.velocity = Vector2.ZERO
if obj.has_method("set_being_held"): if obj.has_method("set_being_held"):
obj.set_being_held(false) 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_layer_value(2, true)
placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(1, true)
placed_obj.set_collision_mask_value(2, 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: if "throw_velocity" in placed_obj:
placed_obj.throw_velocity = Vector2.ZERO placed_obj.throw_velocity = Vector2.ZERO
@@ -3362,6 +3599,7 @@ func _force_place_down(direction: Vector2):
elif _is_player(placed_obj): elif _is_player(placed_obj):
placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_layer_value(1, true)
placed_obj.set_collision_mask_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 placed_obj.velocity = Vector2.ZERO
if placed_obj.has_method("set_being_held"): if placed_obj.has_method("set_being_held"):
placed_obj.set_being_held(false) placed_obj.set_being_held(false)
@@ -3398,6 +3636,14 @@ func take_damage(amount: float, attacker_position: Vector2):
if is_dead: if is_dead:
return 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) # Check for dodge chance (based on DEX)
var _was_dodged = false var _was_dodged = false
if character_stats: 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 velocity = direction_from_attacker * 250.0 # Scaled down for 1x scale
# Face the attacker (opposite of knockback direction) # 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) # Enable knockback state (prevents player control for a short time)
is_knocked_back = true is_knocked_back = true
@@ -3514,6 +3762,7 @@ func _die():
released_obj.set_collision_layer_value(2, true) released_obj.set_collision_layer_value(2, true)
released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(1, true)
released_obj.set_collision_mask_value(2, 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: if "is_being_held" in released_obj:
released_obj.is_being_held = false released_obj.is_being_held = false
if "held_by_player" in released_obj: if "held_by_player" in released_obj:
@@ -3521,6 +3770,7 @@ func _die():
elif _is_player(released_obj): elif _is_player(released_obj):
released_obj.set_collision_layer_value(1, true) released_obj.set_collision_layer_value(1, true)
released_obj.set_collision_mask_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"): if released_obj.has_method("set_being_held"):
released_obj.set_being_held(false) released_obj.set_being_held(false)
@@ -3593,6 +3843,7 @@ func _die():
# Re-enable our collision # Re-enable our collision
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_value(1, true) set_collision_mask_value(1, true)
set_collision_mask_value(7, true) # Re-enable wall collision!
# THEN sync to other clients # THEN sync to other clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): 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 # Re-enable collision in case it was disabled while being carried
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_value(1, true) set_collision_mask_value(1, true)
set_collision_mask_value(7, true) # Re-enable wall collision!
# Reset health and state # Reset health and state
if character_stats: if character_stats:
@@ -3748,6 +4000,7 @@ func _force_holder_to_drop_local(holder_name: String):
# Re-enable collision on dropped player # Re-enable collision on dropped player
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_value(1, true) set_collision_mask_value(1, true)
set_collision_mask_value(7, true) # Re-enable wall collision!
else: else:
print(" ✗ held_object doesn't match self") print(" ✗ held_object doesn't match self")
else: else:
@@ -3766,6 +4019,7 @@ func _sync_respawn(spawn_pos: Vector2):
# Re-enable collision in case it was disabled while being carried # Re-enable collision in case it was disabled while being carried
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_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) # Just teleport and reset on clients (AFTER release is processed)
global_position = spawn_pos global_position = spawn_pos
@@ -3814,6 +4068,14 @@ func _sync_stats_update(kills_count: int, coins_count: int):
character_stats.coin = coins_count character_stats.coin = coins_count
print(name, " stats synced from server: kills=", kills_count, " coins=", 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") @rpc("any_peer", "reliable")
func _sync_equipment(equipment_data: Dictionary): func _sync_equipment(equipment_data: Dictionary):
# Client receives equipment update from server or other clients # Client receives equipment update from server or other clients
@@ -3822,6 +4084,12 @@ func _sync_equipment(equipment_data: Dictionary):
if not character_stats: if not character_stats:
return 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) # On server, only accept if this is a client player (not server's own player)
if multiplayer.is_server(): if multiplayer.is_server():
var the_peer_id = get_multiplayer_authority() var the_peer_id = get_multiplayer_authority()
@@ -3845,15 +4113,19 @@ func _sync_equipment(equipment_data: Dictionary):
func _sync_inventory(inventory_data: Array): func _sync_inventory(inventory_data: Array):
# Client receives inventory update from server # Client receives inventory update from server
# Update inventory to match server's inventory # Update inventory to match server's inventory
# Unlike _sync_equipment, we WANT to receive our own inventory from the server # CRITICAL: Don't accept inventory syncs for our own player
# So we check if we're the server (sender) and ignore, not if we're the authority # 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(): if multiplayer.is_server():
return # Server ignores this (it's the sender) return # Server ignores this (it's the sender)
if not character_stats: if not character_stats:
return 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() character_stats.inventory.clear()
for item_data in inventory_data: for item_data in inventory_data:
if item_data != null: 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 velocity = direction_from_attacker * 250.0
# Face the attacker # 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 # Enable knockback state
is_knocked_back = true is_knocked_back = true

View File

@@ -25,8 +25,8 @@ func _ready():
_apply_color_replacements() _apply_color_replacements()
$SfxSwosh.play() $SfxSwosh.play()
$AnimationPlayer.play("flying") $AnimationPlayer.play("flying")
# Connect area signals # Connect area signals (only if not already connected)
if hit_area: if hit_area and not hit_area.body_entered.is_connected(_on_body_entered):
hit_area.body_entered.connect(_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): func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0, staff_item: Item = null):

View File

@@ -21,8 +21,8 @@ var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup)
func _ready(): func _ready():
$SfxSwosh.play() $SfxSwosh.play()
# Connect area signals # Connect area signals (only if not already connected)
if hit_area: if hit_area and not hit_area.body_entered.is_connected(_on_body_entered):
hit_area.body_entered.connect(_on_body_entered) hit_area.body_entered.connect(_on_body_entered)
func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0): func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0):

321
src/scripts/trap.gd Normal file
View File

@@ -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")

1
src/scripts/trap.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bkp8t4mvdhtqq