added more spell effects, fixed bomb effects, allow to pickup bomb...

This commit is contained in:
2026-01-24 05:20:24 +01:00
parent b9e836d394
commit 9ab4a13244
18 changed files with 715 additions and 158 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bf158atxi7ucy"
path="res://.godot/imported/shade_spell_effects.png-1e71e6c58b206f9920df29b69ad9b76f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/fx/shade_spell_effects.png"
dest_files=["res://.godot/imported/shade_spell_effects.png-1e71e6c58b206f9920df29b69ad9b76f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -6,6 +6,9 @@
[ext_resource type="AudioStream" uid="uid://d4dweg04wrw6a" path="res://assets/audio/sfx/sub_weapons/bomb_fuse.mp3" id="3_fuse"] [ext_resource type="AudioStream" uid="uid://d4dweg04wrw6a" path="res://assets/audio/sfx/sub_weapons/bomb_fuse.mp3" id="3_fuse"]
[ext_resource type="AudioStream" uid="uid://qcb5u7dqw1ck" path="res://assets/audio/sfx/explode_01.wav.mp3" id="4_explode"] [ext_resource type="AudioStream" uid="uid://qcb5u7dqw1ck" path="res://assets/audio/sfx/explode_01.wav.mp3" id="4_explode"]
[ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="5_pickup"] [ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="5_pickup"]
[ext_resource type="AudioStream" uid="uid://nks0upmnsatn" path="res://assets/audio/sfx/ambience/debris-rocks-01.wav.mp3" id="7_h6264"]
[ext_resource type="AudioStream" uid="uid://dpwa2spwtc055" path="res://assets/audio/sfx/ambience/debris-rocks-02.wav.mp3" id="8_fa1rq"]
[ext_resource type="AudioStream" uid="uid://cxl1ltxeqd4ye" path="res://assets/audio/sfx/ambience/debris-rocks-03.wav.mp3" id="9_haynv"]
[sub_resource type="Gradient" id="Gradient_shadow"] [sub_resource type="Gradient" id="Gradient_shadow"]
colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0)
@@ -17,7 +20,7 @@ fill_from = Vector2(0.5, 0.5)
fill_to = Vector2(0.8, 0.8) fill_to = Vector2(0.8, 0.8)
[sub_resource type="CircleShape2D" id="CircleShape2D_bomb"] [sub_resource type="CircleShape2D" id="CircleShape2D_bomb"]
radius = 8.0 radius = 4.5
[sub_resource type="CircleShape2D" id="CircleShape2D_collection"] [sub_resource type="CircleShape2D" id="CircleShape2D_collection"]
radius = 16.0 radius = 16.0
@@ -63,6 +66,14 @@ fill = 1
fill_from = Vector2(0.5, 0.5) fill_from = Vector2(0.5, 0.5)
fill_to = Vector2(0.9102564, 0.9188034) fill_to = Vector2(0.9102564, 0.9188034)
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_tqihf"]
playback_mode = 1
random_pitch = 1.0376596
streams_count = 3
stream_0/stream = ExtResource("7_h6264")
stream_1/stream = ExtResource("8_fa1rq")
stream_2/stream = ExtResource("9_haynv")
[node name="Bomb" type="CharacterBody2D" unique_id=367943636] [node name="Bomb" type="CharacterBody2D" unique_id=367943636]
collision_layer = 2 collision_layer = 2
motion_mode = 1 motion_mode = 1
@@ -75,6 +86,7 @@ scale = Vector2(0.1, 0.1)
texture = SubResource("GradientTexture2D_shadow") texture = SubResource("GradientTexture2D_shadow")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=818862430] [node name="Sprite2D" type="Sprite2D" parent="." unique_id=818862430]
position = Vector2(0, -1)
texture = ExtResource("2_ng1nl") texture = ExtResource("2_ng1nl")
hframes = 20 hframes = 20
vframes = 14 vframes = 14
@@ -95,7 +107,7 @@ collision_mask = 3
[node name="CollisionShape2D" type="CollisionShape2D" parent="BombArea" unique_id=963327610] [node name="CollisionShape2D" type="CollisionShape2D" parent="BombArea" unique_id=963327610]
shape = SubResource("CircleShape2D_bomb") shape = SubResource("CircleShape2D_bomb")
debug_color = Color(0, 0.06808392, 0.70196074, 0.41960785) debug_color = Color(0.29747584, 0.70196074, 0.6174988, 0.41960785)
[node name="CollectionArea" type="Area2D" parent="." unique_id=1088408959] [node name="CollectionArea" type="Area2D" parent="." unique_id=1088408959]
collision_layer = 0 collision_layer = 0
@@ -103,7 +115,7 @@ monitoring = false
[node name="CollisionShape2D" type="CollisionShape2D" parent="CollectionArea" unique_id=1383974781] [node name="CollisionShape2D" type="CollisionShape2D" parent="CollectionArea" unique_id=1383974781]
shape = SubResource("CircleShape2D_collection") shape = SubResource("CircleShape2D_collection")
debug_color = Color(0.38218734, 0.5838239, 0.70196074, 0.41960785) debug_color = Color(0.70196074, 0.51966184, 0.5406604, 0.41960785)
[node name="SfxFuse" type="AudioStreamPlayer2D" parent="." unique_id=1095147141] [node name="SfxFuse" type="AudioStreamPlayer2D" parent="." unique_id=1095147141]
stream = ExtResource("3_fuse") stream = ExtResource("3_fuse")
@@ -125,14 +137,14 @@ bus = &"Sfx"
[node name="FuseParticles" type="GPUParticles2D" parent="." unique_id=1234567890] [node name="FuseParticles" type="GPUParticles2D" parent="." unique_id=1234567890]
z_index = 2 z_index = 2
position = Vector2(6, -5) position = Vector2(6.44, -6.46)
amount = 32 amount = 32
lifetime = 0.3 lifetime = 0.3
randomness = 1.0 randomness = 1.0
process_material = SubResource("ParticleProcessMaterial_fuse") process_material = SubResource("ParticleProcessMaterial_fuse")
[node name="FuseLight" type="PointLight2D" parent="." unique_id=1286608618] [node name="FuseLight" type="PointLight2D" parent="." unique_id=1286608618]
position = Vector2(6, -5) position = Vector2(6.485, -6.335)
enabled = false enabled = false
color = Color(1, 0.4, 0.1, 1) color = Color(1, 0.4, 0.1, 1)
energy = 0.8 energy = 0.8
@@ -143,3 +155,8 @@ enabled = false
color = Color(1, 0.6, 0.2, 1) color = Color(1, 0.6, 0.2, 1)
energy = 2.5 energy = 2.5
texture = SubResource("GradientTexture2D_explosion_light") texture = SubResource("GradientTexture2D_explosion_light")
[node name="SfxDebrisFromParticles" type="AudioStreamPlayer2D" parent="." unique_id=1975206979]
stream = SubResource("AudioStreamRandomizer_tqihf")
attenuation = 1.7411015
panning_strength = 1.05

View File

@@ -0,0 +1,24 @@
[gd_scene format=3 uid="uid://explosion_tile_particle_1"]
[ext_resource type="Script" path="res://scripts/explosion_tile_particle.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://c4ee36hr5f766" path="res://assets/gfx/RPG DUNGEON VOL 3.png" id="2_dungeon"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_1"]
size = Vector2(7, 7)
[node name="ExplosionTileParticle" type="CharacterBody2D"]
z_index = 17
z_as_relative = false
y_sort_enabled = true
collision_layer = 0
collision_mask = 64
script = ExtResource("1_script")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(-3.5, -3.5)
shape = SubResource("RectangleShape2D_1")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_dungeon")
region_enabled = true
region_rect = Rect2(0, 0, 8, 8)

View File

@@ -36,6 +36,7 @@
[ext_resource type="AudioStream" uid="uid://d1ut5lnlch0k2" path="res://assets/audio/sfx/weapons/bow/bow_release3.mp3" id="32_jc3p3"] [ext_resource type="AudioStream" uid="uid://d1ut5lnlch0k2" path="res://assets/audio/sfx/weapons/bow/bow_release3.mp3" id="32_jc3p3"]
[ext_resource type="AudioStream" uid="uid://bvi00vbftbgc5" path="res://assets/audio/sfx/player/ultra_run/shinespark_start.wav" id="35_bj30b"] [ext_resource type="AudioStream" uid="uid://bvi00vbftbgc5" path="res://assets/audio/sfx/player/ultra_run/shinespark_start.wav" id="35_bj30b"]
[ext_resource type="AudioStream" uid="uid://0xm3gyh8051h" path="res://assets/audio/sfx/wizard/incantations/indignation.mp3" id="36_jc3p3"] [ext_resource type="AudioStream" uid="uid://0xm3gyh8051h" path="res://assets/audio/sfx/wizard/incantations/indignation.mp3" id="36_jc3p3"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="37_hax0n"]
[sub_resource type="Gradient" id="Gradient_wqfne"] [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)
@@ -293,6 +294,78 @@ 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="Animation" id="Animation_t4otl"]
length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IncantationSprite:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [2037]
}
[sub_resource type="Animation" id="Animation_j2b1d"]
resource_name = "fire_charging"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IncantationSprite:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.10000002, 0.13333336, 0.16666669, 0.20000002, 0.23333335, 0.26666668, 0.30000004, 0.33333337, 0.3666667, 0.4, 0.43333337, 0.46666673, 0.5, 0.53333336),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053]
}
[sub_resource type="Animation" id="Animation_cs1tg"]
resource_name = "fire_ready"
length = 0.6
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IncantationSprite:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.10000002, 0.13333336, 0.16666669, 0.20000002, 0.23333335, 0.26666668, 0.30000004, 0.33333337, 0.3666667, 0.4, 0.43333337, 0.46666673, 0.5, 0.53333336),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053]
}
[sub_resource type="Animation" id="Animation_hax0n"]
resource_name = "idle"
length = 0.1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IncantationSprite:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [0]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_2dvfe"]
_data = {
&"RESET": SubResource("Animation_t4otl"),
&"fire_charging": SubResource("Animation_j2b1d"),
&"fire_ready": SubResource("Animation_cs1tg"),
&"idle": SubResource("Animation_hax0n")
}
[node name="Player" type="CharacterBody2D" unique_id=937429705] [node name="Player" type="CharacterBody2D" unique_id=937429705]
collision_mask = 67 collision_mask = 67
motion_mode = 1 motion_mode = 1
@@ -493,3 +566,13 @@ volume_db = 5.729
attenuation = 7.727487 attenuation = 7.727487
panning_strength = 1.04 panning_strength = 1.04
bus = &"Sfx" bus = &"Sfx"
[node name="IncantationSprite" type="Sprite2D" parent="." unique_id=1655944614]
texture = ExtResource("37_hax0n")
hframes = 105
vframes = 79
frame = 2037
[node name="AnimationIncantation" type="AnimationPlayer" parent="." unique_id=17658820]
libraries/ = SubResource("AnimationLibrary_2dvfe")
autoplay = &"idle"

View File

@@ -1,28 +1,26 @@
[gd_scene format=3 uid="uid://bvxp7yw8q1k2l"] [gd_scene format=3 uid="uid://bvxp7yw8q1k2l"]
[ext_resource type="Script" path="res://scripts/sword_slash.gd" id="1_sword"] [ext_resource type="Script" uid="uid://bqxbhjq2b4ram" path="res://scripts/sword_slash.gd" id="1_sword"]
[ext_resource type="Texture2D" uid="uid://hib38y541eog" path="res://assets/gfx/items_n_shit.png" id="2_texture"] [ext_resource type="Texture2D" uid="uid://dkpritx47nd4m" path="res://assets/gfx/pickups/items_n_shit.png" id="2_e3omh"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_slash"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_slash"]
size = Vector2(120, 60) size = Vector2(120, 60)
[node name="SwordSlash" type="Node2D"] [node name="SwordSlash" type="Node2D" unique_id=1348241278]
script = ExtResource("1_sword") script = ExtResource("1_sword")
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="." unique_id=1244548324]
texture = ExtResource("2_texture")
rotation = 3.14159 rotation = 3.14159
scale = Vector2(3, 3) scale = Vector2(3, 3)
texture = ExtResource("2_e3omh")
hframes = 20 hframes = 20
vframes = 14 vframes = 14
frame = 60 frame = 60
region_enabled = false
[node name="Area2D" type="Area2D" parent="."] [node name="Area2D" type="Area2D" parent="." unique_id=1569887807]
collision_layer = 4 collision_layer = 4
collision_mask = 3 collision_mask = 3
[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"] [node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1035191880]
position = Vector2(60, 0) position = Vector2(60, 0)
shape = SubResource("RectangleShape2D_slash") shape = SubResource("RectangleShape2D_slash")

View File

@@ -288,14 +288,14 @@ func _stick_to_wall():
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_via_gameworld(arrow_name: String):
func _sync_arrow_collected(): # Route sync through game_world (RPC on arrow caused "node not found" on host).
# Sync arrow collection across network - mark as collected and remove # Collector already added to inventory and will queue_free locally.
if not is_collected: if arrow_name.is_empty():
is_collected = true return
print(name, " arrow collected (synced)") var gw = get_tree().get_first_node_in_group("game_world")
# Queue free on next frame to avoid issues if gw and gw.has_method("_sync_arrow_collected") and multiplayer.has_multiplayer_peer():
call_deferred("queue_free") gw._sync_arrow_collected.rpc(arrow_name)
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func _sync_arrow_hit(target_name: String): func _sync_arrow_hit(target_name: String):
@@ -409,20 +409,19 @@ func _on_collection_area_body_entered(body: Node2D):
print("ERROR: body.character_stats is invalid when trying to collect arrow") print("ERROR: body.character_stats is invalid when trying to collect arrow")
return return
# Capture stable arrow name for sync (route through game_world to avoid RPC path issues)
var arrow_name = name
# Check if player has arrows equipped in offhand # Check if player has arrows equipped in offhand
var offhand_item = body.character_stats.equipment.get("offhand", null) 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": if offhand_item and is_instance_valid(offhand_item) and offhand_item.item_name == "Arrow":
is_collected = true is_collected = true
# Sync arrow collection to all clients
if multiplayer.has_multiplayer_peer():
_sync_arrow_collected.rpc()
$SfxPickup.play() $SfxPickup.play()
# Add directly to equipped arrows # Add directly to equipped arrows
offhand_item.quantity += 1 offhand_item.quantity += 1
body.character_stats.character_changed.emit(body.character_stats) body.character_stats.character_changed.emit(body.character_stats)
print(body.name, " collected arrow from wall! Total arrows: ", offhand_item.quantity) print(body.name, " collected arrow from wall! Total arrows: ", offhand_item.quantity)
_sync_arrow_collected_via_gameworld(arrow_name)
await $SfxPickup.finished await $SfxPickup.finished
queue_free() queue_free()
return return
@@ -436,16 +435,12 @@ func _on_collection_area_body_entered(body: Node2D):
var new_arrow = ItemDatabase.create_item("arrow") var new_arrow = ItemDatabase.create_item("arrow")
if new_arrow and is_instance_valid(new_arrow): if new_arrow and is_instance_valid(new_arrow):
is_collected = true is_collected = true
# Sync arrow collection to all clients
if multiplayer.has_multiplayer_peer():
_sync_arrow_collected.rpc()
$SfxPickup.play() $SfxPickup.play()
new_arrow.quantity = 1 new_arrow.quantity = 1
body.character_stats.equipment["offhand"] = new_arrow body.character_stats.equipment["offhand"] = new_arrow
body.character_stats.character_changed.emit(body.character_stats) body.character_stats.character_changed.emit(body.character_stats)
print(body.name, " collected arrow and re-equipped to offhand!") print(body.name, " collected arrow and re-equipped to offhand!")
_sync_arrow_collected_via_gameworld(arrow_name)
await $SfxPickup.finished await $SfxPickup.finished
queue_free() queue_free()
return return
@@ -457,22 +452,14 @@ func _on_collection_area_body_entered(body: Node2D):
return return
inventory_arrow.quantity = 1 inventory_arrow.quantity = 1
if not body.character_stats.has_method("add_item_to_inventory"): if not body.character_stats.has_method("add_item"):
print("ERROR: character_stats missing add_item_to_inventory method") print("ERROR: character_stats missing add_item method")
return return
var success = body.character_stats.add_item_to_inventory(inventory_arrow) body.character_stats.add_item(inventory_arrow)
if success: is_collected = true
is_collected = true $SfxPickup.play()
print(body.name, " collected arrow from wall into inventory!")
# Sync arrow collection to all clients _sync_arrow_collected_via_gameworld(arrow_name)
if multiplayer.has_multiplayer_peer(): await $SfxPickup.finished
_sync_arrow_collected.rpc() queue_free()
$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

@@ -5,7 +5,7 @@ extends CharacterBody2D
@export var fuse_duration: float = 3.0 # Time until explosion @export var fuse_duration: float = 3.0 # Time until explosion
@export var base_damage: float = 50.0 # Base damage (increased from 30) @export var base_damage: float = 50.0 # Base damage (increased from 30)
@export var damage_radius: float = 48.0 # Area of effect radius (48x48) @export var damage_radius: float = 48.0 # Area of effect radius (48x48)
@export var screenshake_strength: float = 5.0 # Base screenshake strength @export var screenshake_strength: float = 18.0 # Base screenshake strength (stronger)
var player_owner: Node = null var player_owner: Node = null
var is_fused: bool = false var is_fused: bool = false
@@ -21,6 +21,7 @@ var velocity_z: float = 0.0
var gravity_z: float = 500.0 var gravity_z: float = 500.0
var is_airborne: bool = false var is_airborne: bool = false
var throw_velocity: Vector2 = Vector2.ZERO var throw_velocity: Vector2 = Vector2.ZERO
var rotation_speed: float = 0.0 # Angular velocity when thrown
# Blinking animation # Blinking animation
var blink_timer: float = 0.0 var blink_timer: float = 0.0
@@ -43,6 +44,10 @@ var collection_delay: float = 0.2 # Can be collected after 0.2 seconds
# Damage area (larger than collision) # Damage area (larger than collision)
var damage_area_shape: CircleShape2D = null var damage_area_shape: CircleShape2D = null
const TILE_SIZE: int = 16
const TILE_STRIDE: int = 17 # 16 + separation 1
var _explosion_tile_particle_scene: PackedScene = null
func _ready(): func _ready():
# Set collision layer to 2 (interactable objects) so it can be grabbed # Set collision layer to 2 (interactable objects) so it can be grabbed
collision_layer = 2 collision_layer = 2
@@ -62,7 +67,17 @@ func _ready():
if explosion_sprite: if explosion_sprite:
explosion_sprite.visible = false explosion_sprite.visible = false
# Setup damage area (48x48 radius) # Setup shadow (like interactable - visible, under bomb)
if shadow:
shadow.visible = true
shadow.modulate = Color(0, 0, 0, 0.5)
shadow.z_index = -1
# Defer area/shape setup and fuse start may run during physics (e.g. trap damage → throw)
call_deferred("_deferred_ready")
func _deferred_ready():
# Setup damage area (48x48 radius) safe to touch Area2D/shape when not flushing queries
if bomb_area: if bomb_area:
var collision_shape = bomb_area.get_node_or_null("CollisionShape2D") var collision_shape = bomb_area.get_node_or_null("CollisionShape2D")
if collision_shape: if collision_shape:
@@ -70,7 +85,7 @@ func _ready():
damage_area_shape.radius = damage_radius / 2.0 # 24 pixel radius for 48x48 damage_area_shape.radius = damage_radius / 2.0 # 24 pixel radius for 48x48
collision_shape.shape = damage_area_shape collision_shape.shape = damage_area_shape
# Start fuse if not thrown (placed bomb starts fusing immediately) # Start fuse if not thrown (placed bomb starts fusing immediately; thrown bombs start fuse on land)
if not is_thrown: if not is_thrown:
_start_fuse() _start_fuse()
@@ -88,6 +103,12 @@ func setup(target_position: Vector2, owner_player: Node, throw_force: Vector2 =
is_airborne = true is_airborne = true
position_z = 2.5 position_z = 2.5
velocity_z = 100.0 velocity_z = 100.0
# Rotation when thrown (based on throw direction)
if throw_force.length_squared() > 1.0:
var perp = Vector2(-throw_force.y, throw_force.x)
rotation_speed = sign(perp.x + perp.y) * 12.0
else:
rotation_speed = 8.0
# Make sure sprite is visible # Make sure sprite is visible
if sprite: if sprite:
sprite.visible = true sprite.visible = true
@@ -157,17 +178,19 @@ func _physics_process(delta):
velocity_z -= gravity_z * delta velocity_z -= gravity_z * delta
position_z += velocity_z * delta position_z += velocity_z * delta
# Update sprite position based on height # Update sprite position and rotation based on height
if sprite: if sprite:
sprite.position.y = -position_z * 0.5 sprite.position.y = -position_z * 0.5
var height_scale = 1.0 - (position_z / 50.0) * 0.2 var height_scale = 1.0 - (position_z / 50.0) * 0.2
sprite.scale = Vector2.ONE * max(0.8, height_scale) sprite.scale = Vector2.ONE * max(0.8, height_scale)
sprite.rotation += rotation_speed * delta
# Update shadow # Update shadow (like interactable - scale down when airborne for visibility)
if shadow: if shadow:
shadow.visible = true
var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 var shadow_scale = 1.0 - (position_z / 75.0) * 0.5
shadow.scale = Vector2.ONE * max(0.5, shadow_scale) shadow.scale = Vector2.ONE * max(0.5, shadow_scale)
shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 shadow.modulate = Color(0, 0, 0, 0.5 - (position_z / 100.0) * 0.3)
# Apply throw velocity # Apply throw velocity
velocity = throw_velocity velocity = throw_velocity
@@ -176,13 +199,15 @@ func _physics_process(delta):
if position_z <= 0.0: if position_z <= 0.0:
_land() _land()
else: else:
# On ground - reset sprite/shadow # On ground - reset sprite/shadow (shadow visible like interactable)
if sprite: if sprite:
sprite.position.y = 0 sprite.position.y = 0
sprite.scale = Vector2.ONE sprite.scale = Vector2.ONE
sprite.rotation = 0.0
if shadow: if shadow:
shadow.visible = true
shadow.scale = Vector2.ONE shadow.scale = Vector2.ONE
shadow.modulate.a = 0.5 shadow.modulate = Color(0, 0, 0, 0.5)
# Apply friction if on ground # Apply friction if on ground
if not is_airborne: if not is_airborne:
@@ -211,7 +236,7 @@ func _physics_process(delta):
if fuse_timer >= collection_delay: if fuse_timer >= collection_delay:
can_be_collected = true can_be_collected = true
if collection_area: if collection_area:
collection_area.monitoring = true collection_area.set_deferred("monitoring", true)
func _land(): func _land():
is_airborne = false is_airborne = false
@@ -228,9 +253,11 @@ func _explode():
is_exploding = true is_exploding = true
# Hide bomb sprite, show explosion # Hide bomb sprite and shadow, show explosion
if sprite: if sprite:
sprite.visible = false sprite.visible = false
if shadow:
shadow.visible = false
if explosion_sprite: if explosion_sprite:
explosion_sprite.visible = true explosion_sprite.visible = true
explosion_sprite.frame = 0 explosion_sprite.frame = 0
@@ -263,6 +290,11 @@ func _explode():
# Cause screenshake # Cause screenshake
_cause_screenshake() _cause_screenshake()
# Spawn tile debris particles (4 pieces per affected tile, bounce, fade)
_spawn_explosion_tile_particles()
if has_node("SfxDebrisFromParticles"):
$SfxDebrisFromParticles.play()
# Disable collision # Disable collision
if bomb_area: if bomb_area:
bomb_area.set_deferred("monitoring", false) bomb_area.set_deferred("monitoring", false)
@@ -332,6 +364,76 @@ func _deal_explosion_damage():
print("Bomb hit enemy: ", body.name, " for ", final_damage, " damage!") print("Bomb hit enemy: ", body.name, " for ", final_damage, " damage!")
func _spawn_explosion_tile_particles():
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world:
return
var layer = game_world.get_node_or_null("Environment/DungeonLayer0")
if not layer or not layer is TileMapLayer:
return
if not _explosion_tile_particle_scene:
_explosion_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene
if not _explosion_tile_particle_scene:
return
var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D
if not tex:
return
var center = global_position
var r = damage_radius
var layer_pos = center - layer.global_position
var center_cell = layer.local_to_map(layer_pos)
var half_cells = ceili(r / float(TILE_SIZE)) + 1
var parent = get_parent()
if not parent:
parent = game_world.get_node_or_null("Entities")
if not parent:
return
for gx in range(center_cell.x - half_cells, center_cell.x + half_cells + 1):
for gy in range(center_cell.y - half_cells, center_cell.y + half_cells + 1):
var cell = Vector2i(gx, gy)
if layer.get_cell_source_id(cell) < 0:
continue
var atlas = layer.get_cell_atlas_coords(cell)
var world = layer.map_to_local(cell) + layer.global_position
if world.distance_to(center) > r:
continue
var bx = atlas.x * TILE_STRIDE
var by = atlas.y * TILE_STRIDE
var h = 8.0 # TILE_SIZE / 2
var regions = [
Rect2(bx, by, h, h),
Rect2(bx + h, by, h, h),
Rect2(bx, by + h, h, h),
Rect2(bx + h, by + h, h, h)
]
# Direction from explosion center to this tile (outward) particles fly away from bomb
var to_tile = world - center
var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU)
# Half the particles: 2 pieces per tile instead of 4 (indices 0 and 2)
for i in [0, 2]:
var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D
var spr = p.get_node_or_null("Sprite2D") as Sprite2D
if not spr:
p.queue_free()
continue
spr.texture = tex
spr.region_enabled = true
spr.region_rect = regions[i]
p.global_position = world
var speed = randf_range(280.0, 420.0) # Much faster - fly around more
var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
p.velocity = d.normalized() * speed
p.angular_velocity = randf_range(-14.0, 14.0)
p.position_z = 0.0
p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down
parent.add_child(p)
func _cause_screenshake(): func _cause_screenshake():
# Calculate screenshake based on distance from local players # Calculate screenshake based on distance from local players
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
@@ -359,9 +461,9 @@ func _cause_screenshake():
var shake_strength = screenshake_strength / max(1.0, min_distance / 50.0) var shake_strength = screenshake_strength / max(1.0, min_distance / 50.0)
shake_strength = min(shake_strength, screenshake_strength * 2.0) # Cap at 2x base shake_strength = min(shake_strength, screenshake_strength * 2.0) # Cap at 2x base
# Apply screenshake # Apply screenshake (longer duration for bigger boom)
if game_world.has_method("add_screenshake"): if game_world.has_method("add_screenshake"):
game_world.add_screenshake(shake_strength, 0.3) # 0.3 second duration game_world.add_screenshake(shake_strength, 0.5) # 0.5 second duration
func _on_bomb_area_body_entered(_body): func _on_bomb_area_body_entered(_body):
# This is for explosion damage (handled in _deal_explosion_damage) # This is for explosion damage (handled in _deal_explosion_damage)
@@ -390,6 +492,14 @@ func on_grabbed(by_player):
can_collect = true can_collect = true
if can_collect: if can_collect:
# Stop fuse sound, particles, and light when collecting
if has_node("SfxFuse"):
$SfxFuse.stop()
if fuse_particles:
fuse_particles.emitting = false
if fuse_light:
fuse_light.enabled = false
# Create bomb item # Create bomb item
var bomb_item = ItemDatabase.create_item("bomb") var bomb_item = ItemDatabase.create_item("bomb")
if bomb_item: if bomb_item:
@@ -404,6 +514,16 @@ func on_grabbed(by_player):
by_player.character_stats.character_changed.emit(by_player.character_stats) by_player.character_stats.character_changed.emit(by_player.character_stats)
# Show "+1 Bomb" above player
var floating_text_scene = load("res://scenes/floating_text.tscn") as PackedScene
if floating_text_scene and by_player and is_instance_valid(by_player):
var ft = floating_text_scene.instantiate()
var parent = by_player.get_parent()
if parent:
parent.add_child(ft)
ft.global_position = Vector2(by_player.global_position.x, by_player.global_position.y - 20)
ft.setup("+1 Bomb", Color(0.9, 0.5, 0.2), 0.5, 0.5) # Orange-ish
# Play pickup sound # Play pickup sound
if has_node("SfxPickup"): if has_node("SfxPickup"):
$SfxPickup.play() $SfxPickup.play()

View File

@@ -204,6 +204,15 @@ func _deal_periodic_damage():
else: else:
print("Flame spell hit enemy: ", body.name, " for ", final_damage, " damage!") print("Flame spell hit enemy: ", body.name, " for ", final_damage, " damage!")
# Destroy wooden interactable objects (box, barrel, pot, etc.) they burn and break
elif body.is_in_group("interactable_object") and body.has_method("take_fire_damage"):
if "is_being_held" in body and body.is_being_held:
continue # Don't break objects while held
var attacker_pos = player_owner.global_position if player_owner else global_position
body.take_fire_damage(final_damage, attacker_pos)
if is_first_hit:
print("Flame spell burning wooden object: ", body.name, " for ", final_damage, " damage!")
func _on_body_entered(_body): func _on_body_entered(_body):
# Track bodies that enter the area (for periodic damage) # Track bodies that enter the area (for periodic damage)
# Don't add to hit_targets here - we want to deal damage multiple times # Don't add to hit_targets here - we want to deal damage multiple times

View File

@@ -31,6 +31,7 @@ var background: ColorRect = null
var metropolis_font: FontFile = null var metropolis_font: FontFile = null
func _ready(): func _ready():
add_to_group("chat_ui")
network_manager = get_node_or_null("/root/NetworkManager") network_manager = get_node_or_null("/root/NetworkManager")
# Load Metropolis font # Load Metropolis font

View File

@@ -0,0 +1,60 @@
extends CharacterBody2D
# Tile debris from bomb explosion - flies out, bounces off walls, fades
var angular_velocity: float = 0.0
var fade_timer: float = 0.0
var fading: bool = false
const FADE_DELAY: float = 0.8
const BOUNCE_DAMP: float = 0.82 # Keep more speed after bounce - fly around more
# Z-axis physics (fly up, then fall back down with gravity)
var position_z: float = 0.0
var velocity_z: float = 0.0
var acceleration_z: float = -500.0 # Downward gravity
func _physics_process(delta: float) -> void:
if fading:
return
# Slow down over time (slower decay so they keep flying longer)
velocity = velocity.lerp(Vector2.ZERO, delta * 1.8)
if abs(angular_velocity) > 0.1:
rotation += angular_velocity * delta
angular_velocity = lerp(angular_velocity, 0.0, delta * 1.5)
# Z physics: gravity, then integrate
velocity_z += acceleration_z * delta
position_z += velocity_z * delta
if position_z <= 0.0:
position_z = 0.0
velocity_z = 0.0
move_and_slide()
# Bounce off walls (tilemap uses layer 7 = bit 64)
for i in get_slide_collision_count():
var col = get_slide_collision(i)
if col and col.get_collider() is CollisionObject2D:
var wall = col.get_collider() as CollisionObject2D
if wall.get_collision_layer_value(7): # Layer 7 = walls
velocity = velocity.bounce(col.get_normal()) * BOUNCE_DAMP
angular_velocity *= -0.6
break
# Visual: raise sprite when airborne, scale up a little when flying upward
var spr = get_node_or_null("Sprite2D")
if spr:
spr.position.y = -position_z * 0.5
var scale_up = 1.0 + (position_z / 60.0) * 0.25 # Slightly bigger when higher
spr.scale = Vector2.ONE * clampf(scale_up, 1.0, 1.35)
# Fade after delay
fade_timer += delta
if fade_timer >= FADE_DELAY:
fading = true
if spr:
var t = create_tween()
t.tween_property(spr, "modulate:a", 0.0, 0.4)
t.tween_callback(queue_free)
else:
queue_free()

View File

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

View File

@@ -2044,8 +2044,8 @@ func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool:
if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
return false return false
# Check if it's a floor tile (grid value 1) or corridor (grid value 3) # Check if it's a floor (1), door (2), or corridor (3) tile - same as walkable
if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 3: if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 2 and grid[tile_x][tile_y] != 3:
return false return false
# Check if there's a wall between player and target using raycast # Check if there's a wall between player and target using raycast
@@ -6661,6 +6661,20 @@ func _broadcast_object_break(obj_name: String):
if is_inside_tree() and multiplayer.has_multiplayer_peer() and multiplayer.is_server(): if is_inside_tree() and multiplayer.has_multiplayer_peer() and multiplayer.is_server():
_rpc_to_ready_peers("_sync_object_break", [obj_name]) _rpc_to_ready_peers("_sync_object_break", [obj_name])
@rpc("any_peer", "reliable")
func _sync_arrow_collected(arrow_name: String):
# Route arrow collection through game_world to avoid node path issues (RPC was on arrow).
# Collector already added to inventory, played sound, and queue_freed locally.
# Other peers find arrow in Entities by stable name and remove it.
if not is_inside_tree() or arrow_name.is_empty():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var arrow = entities_node.get_node_or_null(arrow_name)
if arrow and is_instance_valid(arrow):
arrow.call_deferred("queue_free")
func _create_level_complete_ui_programmatically() -> Node: func _create_level_complete_ui_programmatically() -> Node:
# Create level complete UI programmatically # Create level complete UI programmatically
var canvas_layer = CanvasLayer.new() var canvas_layer = CanvasLayer.new()

View File

@@ -408,6 +408,28 @@ func can_be_thrown() -> bool:
func can_be_destroyed() -> bool: func can_be_destroyed() -> bool:
return is_destroyable return is_destroyable
func _is_wooden_burnable() -> bool:
var t = object_type if object_type != "" else _get_configured_object_type()
return t in ["Box", "Pot", "LiftableBarrel", "PushableBarrel", "PushableHighBox"]
func take_fire_damage(amount: float, _attacker_position: Vector2) -> void:
if not is_destroyable or is_broken or not _is_wooden_burnable():
return
health -= amount
if health > 0:
return
var game_world = get_tree().get_first_node_in_group("game_world")
if multiplayer.has_multiplayer_peer():
if multiplayer.is_server():
if game_world and game_world.has_method("_rpc_to_ready_peers"):
game_world._rpc_to_ready_peers("_sync_object_break", [name])
_break_into_pieces()
else:
if game_world and game_world.has_method("_sync_object_break"):
game_world._sync_object_break.rpc_id(1, name)
else:
_break_into_pieces()
func on_grabbed(by_player): func on_grabbed(by_player):
# Special handling for chests - open instead of grab # Special handling for chests - open instead of grab
if object_type == "Chest" and not is_chest_opened: if object_type == "Chest" and not is_chest_opened:
@@ -526,9 +548,9 @@ func _convert_to_bomb_projectile(by_player, force: Vector2):
if bomb.has_node("Sprite2D"): if bomb.has_node("Sprite2D"):
bomb.get_node("Sprite2D").visible = true bomb.get_node("Sprite2D").visible = true
# Sync bomb throw to other clients # Sync bomb throw to other clients (pass our name so they can free the lifted bomb)
if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority() and by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority() and by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree():
by_player._rpc_to_ready_peers("_sync_throw_bomb", [current_pos, force]) by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force])
# Remove the interactable object # Remove the interactable object
queue_free() queue_free()

View File

@@ -514,8 +514,9 @@ func _update_ui():
sprite.scale = Vector2(2.0, 2.0) # 2x size as requested sprite.scale = Vector2(2.0, 2.0) # 2x size as requested
button.add_child(sprite) button.add_child(sprite)
# Add quantity label if item can have multiple (like arrows) # Add quantity label if item can have multiple (like arrows, bombs)
if equipped_item.can_have_multiple_of and equipped_item.quantity > 1: var show_qty = equipped_item.can_have_multiple_of and (equipped_item.quantity > 1 or equipped_item.weapon_type == Item.WeaponType.BOMB)
if show_qty:
var quantity_label = Label.new() var quantity_label = Label.new()
quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
@@ -530,6 +531,7 @@ func _update_ui():
font_file.font_data = dmg_font_resource font_file.font_data = dmg_font_resource
quantity_label.add_theme_font_override("font", font_file) quantity_label.add_theme_font_override("font", font_file)
quantity_label.add_theme_font_size_override("font_size", 16) quantity_label.add_theme_font_size_override("font_size", 16)
quantity_label.add_theme_color_override("font_color", Color.GREEN)
quantity_label.z_index = 100 # High z-index to show above item sprite quantity_label.z_index = 100 # High z-index to show above item sprite
quantity_label.z_as_relative = false # Absolute z-index quantity_label.z_as_relative = false # Absolute z-index
button.add_child(quantity_label) button.add_child(quantity_label)
@@ -610,6 +612,7 @@ func _update_ui():
font_file.font_data = dmg_font_resource font_file.font_data = dmg_font_resource
quantity_label.add_theme_font_override("font", font_file) quantity_label.add_theme_font_override("font", font_file)
quantity_label.add_theme_font_size_override("font_size", 16) quantity_label.add_theme_font_size_override("font_size", 16)
quantity_label.add_theme_color_override("font_color", Color.GREEN)
quantity_label.z_index = 100 # High z-index to show above item sprite quantity_label.z_index = 100 # High z-index to show above item sprite
button.add_child(quantity_label) button.add_child(quantity_label)

View File

@@ -40,24 +40,24 @@ var use_function = null
var item_name: String = "Red Apple" var item_name: String = "Red Apple"
var description: String = "Restores 5 HP" var description: String = "Restores 5 HP"
var spritePath: String = "res://assets/gfx/items_n_shit.png" var spritePath: String = "res://assets/gfx/pickups/items_n_shit.png"
var equipmentPath: String = "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Basic Body/BasicRed.png" var equipmentPath: String = "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Basic Body/BasicRed.png"
var colorReplacements: Array = [] var colorReplacements: Array = []
var spriteFrames:Vector2i = Vector2i(20,14) var spriteFrames: Vector2i = Vector2i(20, 14)
var spriteFrame:int = 0 var spriteFrame: int = 0
var modifiers: Dictionary = { "hp": +20 } var modifiers: Dictionary = {"hp": + 20}
var duration: float = 0 var duration: float = 0
var buy_cost: int = 10 var buy_cost: int = 10
var sell_worth: int = 3 var sell_worth: int = 3
var sellable:bool = true var sellable: bool = true
var item_type: ItemType = ItemType.Restoration var item_type: ItemType = ItemType.Restoration
var equipment_type: EquipmentType = EquipmentType.NONE var equipment_type: EquipmentType = EquipmentType.NONE
var weapon_type: WeaponType = WeaponType.NONE 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 = 2.0 # Item weight for encumbrance system (default 2.0 for equipment) 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

@@ -73,8 +73,10 @@ var bow_charge_percentage: float = 1.0 # 0.5, 0.75, or 1.0 based on charge time
var is_charging_spell: bool = false # True when holding grab with spellbook var is_charging_spell: bool = false # True when holding grab with spellbook
var spell_charge_start_time: float = 0.0 var spell_charge_start_time: float = 0.0
var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second) var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second)
var use_spell_charge_particles: bool = false # If true, use red_star particles; if false, use AnimationIncantation
var spell_charge_particles: Node2D = null # Particle system for charging var spell_charge_particles: Node2D = null # Particle system for charging
var spell_charge_particle_timer: float = 0.0 # Timer for spawning particles var spell_charge_particle_timer: float = 0.0 # Timer for spawning particles
var spell_incantation_fire_ready_shown: bool = false # Track when we've switched to fire_ready
var spell_charge_tint: Color = Color(2.0, 0.2, 0.2, 2.0) # Tint color when fully charged var spell_charge_tint: Color = Color(2.0, 0.2, 0.2, 2.0) # Tint color when fully charged
var spell_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation var spell_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation
var spell_charge_tint_pulse_speed: float = 8.0 # Speed of tint pulse animation while charging var spell_charge_tint_pulse_speed: float = 8.0 # Speed of tint pulse animation while charging
@@ -92,6 +94,10 @@ var burn_damage_timer: float = 0.0 # Timer for burn damage ticks
var movement_lock_timer: float = 0.0 # Lock movement when bow is released var movement_lock_timer: float = 0.0 # Lock movement when bow is released
var direction_lock_timer: float = 0.0 # Lock facing direction when attacking var direction_lock_timer: float = 0.0 # Lock facing direction when attacking
var locked_facing_direction: Vector2 = Vector2.ZERO # Locked facing direction during attack var locked_facing_direction: Vector2 = Vector2.ZERO # Locked facing direction during attack
var damage_direction_lock_timer: float = 0.0 # Lock facing when taking damage (enemies/players)
var damage_direction_lock_duration: float = 0.3 # Duration to block direction change after damage
var empty_bow_shot_attempts: int = 0
var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync)
var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) var sword_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
@@ -1493,6 +1499,10 @@ func _get_direction_from_vector(vec: Vector2) -> int:
# Update facing direction from mouse position (called by GameWorld) # Update facing direction from mouse position (called by GameWorld)
func _update_facing_from_mouse(mouse_direction: Vector2): func _update_facing_from_mouse(mouse_direction: Vector2):
# Don't update facing when dead
if is_dead:
return
# Only update if using keyboard input (not gamepad) # Only update if using keyboard input (not gamepad)
if input_device != -1: if input_device != -1:
return return
@@ -1505,6 +1515,10 @@ func _update_facing_from_mouse(mouse_direction: Vector2):
if direction_lock_timer > 0.0: if direction_lock_timer > 0.0:
return return
# Don't update if direction is locked (taking damage from enemies/players)
if damage_direction_lock_timer > 0.0:
return
# Mark that mouse control is active (prevents movement keys from overriding attack direction) # Mark that mouse control is active (prevents movement keys from overriding attack direction)
mouse_control_active = true mouse_control_active = true
@@ -1739,6 +1753,12 @@ func _physics_process(delta):
if direction_lock_timer <= 0.0: if direction_lock_timer <= 0.0:
direction_lock_timer = 0.0 direction_lock_timer = 0.0
# Update damage direction lock timer (block facing change when taking damage)
if damage_direction_lock_timer > 0.0:
damage_direction_lock_timer -= delta
if damage_direction_lock_timer <= 0.0:
damage_direction_lock_timer = 0.0
# Update spell charging # Update spell charging
if is_charging_spell: if is_charging_spell:
var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
@@ -1747,6 +1767,7 @@ func _physics_process(delta):
# Update particles (spawn and animate) # Update particles (spawn and animate)
spell_charge_particle_timer += delta spell_charge_particle_timer += delta
_update_spell_charge_particles(charge_progress) _update_spell_charge_particles(charge_progress)
_update_spell_charge_incantation(charge_progress)
# Update tint pulse timer when fully charged # Update tint pulse timer when fully charged
if charge_progress >= 1.0: if charge_progress >= 1.0:
@@ -2292,6 +2313,7 @@ func _handle_interactions():
spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_charge_start_time = Time.get_ticks_msec() / 1000.0
spell_incantation_played = false # Reset flag when starting new charge spell_incantation_played = false # Reset flag when starting new charge
_start_spell_charge_particles() _start_spell_charge_particles()
_start_spell_charge_incantation()
# Play spell charging sound (incantation plays when fully charged) # Play spell charging sound (incantation plays when fully charged)
if has_node("SfxSpellCharge"): if has_node("SfxSpellCharge"):
@@ -2314,6 +2336,7 @@ func _handle_interactions():
is_charging_spell = false is_charging_spell = false
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() _clear_spell_charge_tint()
# Return to IDLE animation # Return to IDLE animation
@@ -2351,6 +2374,7 @@ func _handle_interactions():
is_charging_spell = false is_charging_spell = false
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() # This will restore original tints _clear_spell_charge_tint() # This will restore original tints
# Stop spell charging sound, but let incantation play to completion # Stop spell charging sound, but let incantation play to completion
@@ -2369,6 +2393,7 @@ func _handle_interactions():
is_charging_spell = false is_charging_spell = false
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() # This will restore original tints _clear_spell_charge_tint() # This will restore original tints
# Return to IDLE animation # Return to IDLE animation
@@ -2393,6 +2418,7 @@ func _handle_interactions():
is_charging_spell = false is_charging_spell = false
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() _clear_spell_charge_tint()
# Return to IDLE animation # Return to IDLE animation
@@ -2788,6 +2814,12 @@ func _try_grab():
closest_body = body closest_body = body
if closest_body: if closest_body:
# Placed bomb (attack_bomb with fuse): collect to inventory, don't lift
if "is_fused" in closest_body and "can_be_collected" in closest_body and "player_owner" in closest_body:
if closest_body.has_method("can_be_grabbed") and closest_body.can_be_grabbed() and closest_body.has_method("on_grabbed"):
closest_body.on_grabbed(self)
return
held_object = closest_body held_object = closest_body
# Store the initial positions - don't change the grabbed object's position yet! # Store the initial positions - don't change the grabbed object's position yet!
initial_grab_position = closest_body.global_position initial_grab_position = closest_body.global_position
@@ -3363,9 +3395,31 @@ func _place_down_object():
if not held_object: if not held_object:
return return
# Place object in front of player based on facing direction (mouse or movement)
var place_pos = _find_closest_place_pos(facing_direction_vector, held_object)
var placed_obj = held_object var placed_obj = held_object
var place_pos = _find_closest_place_pos(facing_direction_vector, placed_obj)
# Dwarf dropping bomb: place attack_bomb with fuse lit (explodes if not picked up in time)
if "object_type" in placed_obj and placed_obj.object_type == "Bomb":
if not _can_place_down_at(place_pos, placed_obj):
return
var bomb_name = placed_obj.name
held_object = null
grab_offset = Vector2.ZERO
is_lifting = false
is_pushing = false
placed_obj.queue_free()
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
get_parent().add_child(bomb)
bomb.global_position = place_pos
bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse starts in _deferred_ready
if multiplayer.has_multiplayer_peer():
bomb.set_multiplayer_authority(get_multiplayer_authority())
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_bomb_dropped", [bomb_name, place_pos])
print(name, " dropped bomb at ", place_pos, " (fuse lit)")
return
print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")") print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")")
@@ -3484,6 +3538,10 @@ func _perform_attack():
# Round to 1 decimal place # Round to 1 decimal place
final_damage = round(final_damage * 10.0) / 10.0 final_damage = round(final_damage * 10.0) / 10.0
# Track what we spawned so we only sync when we actually shot a projectile
var spawned_projectile_type: String = ""
var sync_arrow_name: String = "" # Stable name for arrow sync (multiplayer)
# Handle bow attacks - require arrows in off-hand # Handle bow attacks - require arrows in off-hand
if is_bow: if is_bow:
# Check for arrows in off-hand # Check for arrows in off-hand
@@ -3493,12 +3551,19 @@ func _perform_attack():
if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION: if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION:
arrows = offhand_item arrows = offhand_item
# Reset empty-bow counter when we have arrows
if arrows and arrows.quantity > 0:
empty_bow_shot_attempts = 0
# Only spawn arrow if we have arrows # Only spawn arrow if we have arrows
if arrows and arrows.quantity > 0: if arrows and arrows.quantity > 0:
if attack_arrow_scene: if attack_arrow_scene:
spawned_projectile_type = "arrow"
$SfxBowShoot.play() $SfxBowShoot.play()
var arrow_projectile = attack_arrow_scene.instantiate() var arrow_projectile = attack_arrow_scene.instantiate()
sync_arrow_name = "arrow_%d_%d" % [multiplayer.get_unique_id(), _arrow_spawn_seq]
_arrow_spawn_seq += 1
arrow_projectile.name = sync_arrow_name
get_parent().add_child(arrow_projectile) get_parent().add_child(arrow_projectile)
# Spawn arrow 4 pixels in the direction player is looking # Spawn arrow 4 pixels in the direction player is looking
var arrow_spawn_pos = global_position + (attack_direction * 4.0) var arrow_spawn_pos = global_position + (attack_direction * 4.0)
@@ -3521,14 +3586,20 @@ func _perform_attack():
character_stats.character_changed.emit(character_stats) character_stats.character_changed.emit(character_stats)
print(name, " shot arrow! Arrows remaining: ", remaining) print(name, " shot arrow! Arrows remaining: ", remaining)
else: else:
# Play bow animation but no projectile # Play bow animation but no projectile - DO NOT sync attack (no arrow spawned)
# Play sound for trying to shoot without arrows
if has_node("SfxBowWithoutArrow"): if has_node("SfxBowWithoutArrow"):
$SfxBowWithoutArrow.play() $SfxBowWithoutArrow.play()
print(name, " tried to shoot but has no arrows!") print(name, " tried to shoot but has no arrows!")
# Track empty bow attempts; after 3, unequip bow and equip another weapon
empty_bow_shot_attempts += 1
if empty_bow_shot_attempts >= 3:
empty_bow_shot_attempts = 0
_unequip_bow_and_equip_other_weapon()
elif is_staff: elif is_staff:
# Spawn staff projectile for staff weapons # Spawn staff projectile for staff weapons
if staff_projectile_scene and equipped_weapon: if staff_projectile_scene and equipped_weapon:
spawned_projectile_type = "staff"
var projectile = staff_projectile_scene.instantiate() var projectile = staff_projectile_scene.instantiate()
get_parent().add_child(projectile) get_parent().add_child(projectile)
projectile.setup(attack_direction, self, final_damage, equipped_weapon) projectile.setup(attack_direction, self, final_damage, equipped_weapon)
@@ -3542,6 +3613,7 @@ func _perform_attack():
else: else:
# Spawn sword projectile for non-bow/staff weapons # Spawn sword projectile for non-bow/staff weapons
if sword_projectile_scene: if sword_projectile_scene:
spawned_projectile_type = "sword"
var projectile = sword_projectile_scene.instantiate() var projectile = sword_projectile_scene.instantiate()
get_parent().add_child(projectile) get_parent().add_child(projectile)
projectile.setup(attack_direction, self, final_damage) projectile.setup(attack_direction, self, final_damage)
@@ -3553,9 +3625,10 @@ func _perform_attack():
projectile.global_position = global_position + spawn_offset projectile.global_position = global_position + spawn_offset
print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
# Sync attack over network # Sync attack over network only when we actually spawned a projectile
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): if spawned_projectile_type != "" and multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage]) var arrow_name_arg = sync_arrow_name if spawned_projectile_type == "arrow" else ""
_rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage, spawned_projectile_type, arrow_name_arg])
# Reset attack cooldown (instant if cooldown is 0) # Reset attack cooldown (instant if cooldown is 0)
if attack_cooldown > 0: if attack_cooldown > 0:
@@ -3564,6 +3637,64 @@ func _perform_attack():
can_attack = true can_attack = true
is_attacking = false is_attacking = false
func _unequip_bow_and_equip_other_weapon():
# After 3 empty bow shots: unequip bow, equip another mainhand weapon if any, show messages
if not is_multiplayer_authority() or not character_stats:
return
var mainhand = character_stats.equipment.get("mainhand", null)
if not mainhand or mainhand.weapon_type != Item.WeaponType.BOW:
return
# Unequip bow (moves it to inventory)
character_stats.unequip_item(mainhand, true)
# Show "Bow unequipped" message
_show_equipment_message("Bow unequipped.")
# Find first mainhand weapon in inventory that is not a bow
var other_weapon = null
for i in range(character_stats.inventory.size()):
var it = character_stats.inventory[i]
if not it:
continue
if it.equipment_type != Item.EquipmentType.MAINHAND:
continue
if it.weapon_type == Item.WeaponType.BOW:
continue
other_weapon = it
break
if other_weapon:
character_stats.equip_item(other_weapon, -1)
_show_equipment_message("%s equipped." % other_weapon.item_name)
# Sync equipment/inventory to other clients (same as _on_character_changed)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var equipment_data = {}
for slot_name in character_stats.equipment.keys():
var item = character_stats.equipment[slot_name]
if item:
equipment_data[slot_name] = item.save()
else:
equipment_data[slot_name] = null
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
_rpc_to_ready_peers("_sync_inventory", [inventory_data])
_apply_appearance_to_sprites()
var other_name = other_weapon.item_name if other_weapon else "none"
print(name, " unequipped bow (no arrows x3); other weapon: ", other_name)
func _show_equipment_message(text: String):
# Local-only so the player who unequipped always sees it (host or client)
var chat_ui = get_tree().get_first_node_in_group("chat_ui")
if chat_ui and chat_ui.has_method("add_local_message"):
chat_ui.add_local_message("System", text)
func _create_bomb_object(): func _create_bomb_object():
# Dwarf: Create interactable bomb object that can be lifted/thrown # Dwarf: Create interactable bomb object that can be lifted/thrown
if not is_multiplayer_authority(): if not is_multiplayer_authority():
@@ -3629,9 +3760,13 @@ func _create_bomb_object():
bomb_obj.on_grabbed(self) bomb_obj.on_grabbed(self)
# Immediately lift the bomb (Dwarf lifts it directly) # Immediately lift the bomb (Dwarf lifts it directly)
# Set is_lifting BEFORE calling _lift_object to prevent it from being reset
is_lifting = true is_lifting = true
is_pushing = false is_pushing = false
push_axis = _snap_to_8_directions(facing_direction_vector) if facing_direction_vector.length() > 0.1 else Vector2.DOWN
if "is_being_held" in bomb_obj:
bomb_obj.is_being_held = true
if "held_by_player" in bomb_obj:
bomb_obj.held_by_player = self
# Freeze the bomb # Freeze the bomb
if "is_frozen" in bomb_obj: if "is_frozen" in bomb_obj:
@@ -3644,19 +3779,14 @@ func _create_bomb_object():
# Play lift animation # Play lift animation
_set_animation("LIFT") _set_animation("LIFT")
# Sync to network # Sync bomb spawn to other clients so they see it when lifted, then sync grab state
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(bomb_obj) var obj_name = bomb_obj.name
if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree(): if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree():
_rpc_to_ready_peers("_sync_create_bomb_object", [obj_name, bomb_obj.global_position])
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting
print(name, " created bomb object! Remaining bombs: ", remaining) print(name, " created bomb object! Remaining bombs: ", remaining)
# Sync grab to network
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(bomb_obj)
if obj_name != "":
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting
func _throw_bomb(_target_position: Vector2): func _throw_bomb(_target_position: Vector2):
# Legacy function - kept for Human/Elf if needed, but Dwarf uses _create_bomb_object now # Legacy function - kept for Human/Elf if needed, but Dwarf uses _create_bomb_object now
@@ -3787,10 +3917,9 @@ func _can_cast_spell_at(target_position: Vector2) -> bool:
if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
return false return false
# Check if it's a floor tile (grid value 1) or corridor (grid value 3) # Check if it's floor (1), door (2), or corridor (3) - same as walkable
# Allow casting on both floor and corridor tiles
var grid_value = grid[tile_x][tile_y] var grid_value = grid[tile_x][tile_y]
if grid_value != 1 and grid_value != 3: if grid_value != 1 and grid_value != 2 and grid_value != 3:
return false return false
# Check if there's a wall between player and target using raycast # Check if there's a wall between player and target using raycast
@@ -3811,7 +3940,9 @@ func _can_cast_spell_at(target_position: Vector2) -> bool:
return true return true
func _start_spell_charge_particles(): func _start_spell_charge_particles():
# Create particle system for spell charging # Create particle system for spell charging (only if enabled)
if not use_spell_charge_particles:
return
if spell_charge_particles: if spell_charge_particles:
_stop_spell_charge_particles() _stop_spell_charge_particles()
@@ -3821,8 +3952,8 @@ func _start_spell_charge_particles():
spell_charge_particle_timer = 0.0 spell_charge_particle_timer = 0.0
func _update_spell_charge_particles(charge_progress: float): func _update_spell_charge_particles(charge_progress: float):
# Update particle system based on charge progress # Update particle system based on charge progress (skip if disabled)
if not spell_charge_particles or not is_instance_valid(spell_charge_particles): if not use_spell_charge_particles or not spell_charge_particles or not is_instance_valid(spell_charge_particles):
return return
var star_texture = load("res://assets/gfx/fx/magic/red_star.png") var star_texture = load("res://assets/gfx/fx/magic/red_star.png")
@@ -3892,6 +4023,26 @@ func _stop_spell_charge_particles():
spell_charge_particles.queue_free() spell_charge_particles.queue_free()
spell_charge_particles = null spell_charge_particles = null
func _start_spell_charge_incantation():
# Play fire_charging on AnimationIncantation when starting spell charge
spell_incantation_fire_ready_shown = false
if has_node("AnimationIncantation"):
$AnimationIncantation.play("fire_charging")
func _update_spell_charge_incantation(charge_progress: float):
# Switch to fire_ready when fully charged (fire_charging already playing from start)
if not has_node("AnimationIncantation"):
return
if charge_progress >= 1.0 and not spell_incantation_fire_ready_shown:
$AnimationIncantation.play("fire_ready")
spell_incantation_fire_ready_shown = true
func _stop_spell_charge_incantation():
# Reset incantation when spell charge ends
spell_incantation_fire_ready_shown = false
if has_node("AnimationIncantation"):
$AnimationIncantation.play("idle")
func _apply_spell_charge_tint(): func _apply_spell_charge_tint():
# Apply pulsing tint to all sprite layers when fully charged using shader parameters # Apply pulsing tint to all sprite layers when fully charged using shader parameters
# Pulse between original tint and spell charge tint # Pulse between original tint and spell charge tint
@@ -4129,6 +4280,7 @@ func _sync_spell_charge_start():
is_charging_spell = true is_charging_spell = true
spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_charge_start_time = Time.get_ticks_msec() / 1000.0
_start_spell_charge_particles() _start_spell_charge_particles()
_start_spell_charge_incantation()
print(name, " (synced) started charging spell") print(name, " (synced) started charging spell")
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
@@ -4138,6 +4290,7 @@ func _sync_spell_charge_end():
is_charging_spell = false is_charging_spell = false
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() _clear_spell_charge_tint()
# Return to IDLE animation # Return to IDLE animation
@@ -4486,76 +4639,51 @@ func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bo
shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 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, charge_percentage: float = 1.0): func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float = 1.0, projectile_type: String = "sword", arrow_name: String = ""):
# Sync attack to other clients # Sync attack to other clients. Use projectile_type from authority (what was actually spawned),
# Check if node still exists and is valid before processing # not equipment - fixes no-arrows and post-unequip bow desync.
if not is_inside_tree() or not is_instance_valid(self): if not is_inside_tree() or not is_instance_valid(self):
return return
if not is_multiplayer_authority(): if not is_multiplayer_authority():
current_direction = direction as Direction current_direction = direction as Direction
# Determine weapon type for animation and projectile # Set animation from projectile_type (authority knows what they shot)
var equipped_weapon = null match projectile_type:
var is_staff = false "staff":
var is_bow = false _set_animation("STAFF")
if character_stats and character_stats.equipment.has("mainhand"): "arrow":
equipped_weapon = character_stats.equipment["mainhand"] _set_animation("BOW")
if equipped_weapon: _:
if equipped_weapon.weapon_type == Item.WeaponType.STAFF: _set_animation("SWORD")
is_staff = true
elif equipped_weapon.weapon_type == Item.WeaponType.BOW:
is_bow = true
# Set appropriate animation
if is_staff:
_set_animation("STAFF")
elif is_bow:
_set_animation("BOW")
else:
_set_animation("SWORD")
# Delay before spawning projectile # Delay before spawning projectile
await get_tree().create_timer(0.15).timeout await get_tree().create_timer(0.15).timeout
# Check again after delay - node might have been destroyed
if not is_inside_tree() or not is_instance_valid(self): if not is_inside_tree() or not is_instance_valid(self):
return return
# Spawn appropriate projectile on client # Spawn only what authority actually spawned (ignore equipment)
if is_staff and staff_projectile_scene and equipped_weapon: if projectile_type == "staff" and staff_projectile_scene:
var equipped_weapon = character_stats.equipment.get("mainhand", null) if character_stats else null
var projectile = staff_projectile_scene.instantiate() var projectile = staff_projectile_scene.instantiate()
get_parent().add_child(projectile) get_parent().add_child(projectile)
projectile.setup(attack_dir, self, 20.0, equipped_weapon) projectile.setup(attack_dir, self, 20.0, equipped_weapon if equipped_weapon else null)
# Spawn projectile a bit in front of the player var spawn_offset = attack_dir * 10.0
var spawn_offset = attack_dir * 10.0 # 10 pixels in front
projectile.global_position = global_position + spawn_offset projectile.global_position = global_position + spawn_offset
print(name, " performed synced staff attack!") print(name, " performed synced staff attack!")
elif is_bow: elif projectile_type == "arrow" and attack_arrow_scene:
# For bow attacks, check if we have arrows (same logic as host) var arrow_projectile = attack_arrow_scene.instantiate()
var arrows = null if arrow_name != "":
if character_stats and character_stats.equipment.has("offhand"): arrow_projectile.name = arrow_name
var offhand_item = character_stats.equipment["offhand"] get_parent().add_child(arrow_projectile)
if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION: arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage)
arrows = offhand_item print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)")
elif (projectile_type == "sword" or projectile_type == "") and sword_projectile_scene:
# Only spawn arrow if we have arrows (matches host behavior)
if arrows and arrows.quantity > 0:
if attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate()
get_parent().add_child(arrow_projectile)
# Use charge percentage from sync (matches local player's arrow)
arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage)
print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)")
else:
# No arrows - just play animation, no projectile (matches host behavior)
print(name, " performed synced bow attack without arrows (no projectile)")
elif sword_projectile_scene:
var projectile = sword_projectile_scene.instantiate() var projectile = sword_projectile_scene.instantiate()
get_parent().add_child(projectile) get_parent().add_child(projectile)
projectile.setup(attack_dir, self) projectile.setup(attack_dir, self)
# Spawn projectile a bit in front of the player var spawn_offset = attack_dir * 10.0
var spawn_offset = attack_dir * 10.0 # 10 pixels in front
projectile.global_position = global_position + spawn_offset projectile.global_position = global_position + spawn_offset
print(name, " performed synced attack!") print(name, " performed synced attack!")
@@ -4575,9 +4703,54 @@ func _sync_bow_charge_end():
_clear_bow_charge_tint() _clear_bow_charge_tint()
print(name, " (synced) ended charging bow") print(name, " (synced) ended charging bow")
@rpc("any_peer", "reliable")
func _sync_create_bomb_object(bomb_name: String, spawn_pos: Vector2):
# Sync Dwarf's lifted bomb spawn to other clients so they see it when held
if is_multiplayer_authority():
return
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world:
return
var entities_node = game_world.get_node_or_null("Entities")
if not entities_node:
return
if entities_node.get_node_or_null(bomb_name):
return # Already exists (e.g. duplicate RPC)
var interactable_scene = load("res://scenes/interactable_object.tscn") as PackedScene
if not interactable_scene:
return
var bomb_obj = interactable_scene.instantiate()
bomb_obj.name = bomb_name
bomb_obj.global_position = spawn_pos
if multiplayer.has_multiplayer_peer():
bomb_obj.set_multiplayer_authority(get_multiplayer_authority())
entities_node.add_child(bomb_obj)
bomb_obj.setup_bomb()
print(name, " (synced) created bomb object ", bomb_name, " at ", spawn_pos)
@rpc("any_peer", "reliable")
func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2):
# Sync Dwarf drop: free lifted bomb on clients, spawn attack_bomb with fuse lit
if not is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
var entities_node = game_world.get_node_or_null("Entities") if game_world else null
if entities_node and bomb_name.begins_with("BombObject_"):
var lifted = entities_node.get_node_or_null(bomb_name)
if lifted and is_instance_valid(lifted):
lifted.queue_free()
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
get_parent().add_child(bomb)
bomb.global_position = place_pos
bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit
if multiplayer.has_multiplayer_peer():
bomb.set_multiplayer_authority(get_multiplayer_authority())
print(name, " (synced) dropped bomb at ", place_pos)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_place_bomb(target_pos: Vector2): func _sync_place_bomb(target_pos: Vector2):
# Sync bomb placement to other clients # Sync bomb placement to other clients (Human/Elf)
if not is_multiplayer_authority(): if not is_multiplayer_authority():
if not attack_bomb_scene: if not attack_bomb_scene:
return return
@@ -4593,24 +4766,23 @@ func _sync_place_bomb(target_pos: Vector2):
print(name, " (synced) placed bomb at ", target_pos) print(name, " (synced) placed bomb at ", target_pos)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_throw_bomb(bomb_pos: Vector2, throw_force: Vector2): func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2):
# Sync bomb throw to other clients # Sync bomb throw to other clients; free lifted bomb (BombObject_*) if it exists
if not is_multiplayer_authority(): if not is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
var entities_node = game_world.get_node_or_null("Entities") if game_world else null
if entities_node and bomb_name.begins_with("BombObject_"):
var lifted = entities_node.get_node_or_null(bomb_name)
if lifted and is_instance_valid(lifted):
lifted.queue_free()
if not attack_bomb_scene: if not attack_bomb_scene:
return return
# Spawn bomb projectile at position
var bomb = attack_bomb_scene.instantiate() var bomb = attack_bomb_scene.instantiate()
get_parent().add_child(bomb) get_parent().add_child(bomb)
bomb.global_position = bomb_pos bomb.global_position = bomb_pos
# Setup bomb with throw physics
bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown
# Make sure bomb sprite is visible
if bomb.has_node("Sprite2D"): if bomb.has_node("Sprite2D"):
bomb.get_node("Sprite2D").visible = true bomb.get_node("Sprite2D").visible = true
print(name, " (synced) threw bomb from ", bomb_pos) print(name, " (synced) threw bomb from ", bomb_pos)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
@@ -5153,6 +5325,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
is_charging_spell = false is_charging_spell = false
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() _clear_spell_charge_tint()
# Return to IDLE animation # Return to IDLE animation
@@ -5223,6 +5396,9 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
# Play damage animation # Play damage animation
_set_animation("DAMAGE") _set_animation("DAMAGE")
# Lock facing direction briefly so player can't change it while taking damage
damage_direction_lock_timer = damage_direction_lock_duration
# Only apply knockback if not burn damage # Only apply knockback if not burn damage
if not is_burn_damage: if not is_burn_damage:
# Calculate direction FROM attacker TO victim # Calculate direction FROM attacker TO victim
@@ -5280,6 +5456,7 @@ func _die():
is_dead = true # Ensure flag is set is_dead = true # Ensure flag is set
velocity = Vector2.ZERO velocity = Vector2.ZERO
is_knocked_back = false is_knocked_back = false
damage_direction_lock_timer = 0.0
# CRITICAL: Release any held object/player BEFORE dying to restore their collision layers # CRITICAL: Release any held object/player BEFORE dying to restore their collision layers
if held_object: if held_object:
@@ -5417,6 +5594,7 @@ func _respawn():
velocity = Vector2.ZERO velocity = Vector2.ZERO
is_knocked_back = false is_knocked_back = false
is_airborne = false is_airborne = false
damage_direction_lock_timer = 0.0
position_z = 0.0 position_z = 0.0
velocity_z = 0.0 velocity_z = 0.0