added so you can choose race when starting the game.

This commit is contained in:
2026-01-30 08:31:33 +01:00
parent dabec8a119
commit 3b2af36231
73 changed files with 4241 additions and 1107 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cj3adlbm2ksl4"
path="res://.godot/imported/characters.png-553a9ff6a8c9c16b8c48788bb39c11f3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/character_select/characters.png"
dest_files=["res://.godot/imported/characters.png-553a9ff6a8c9c16b8c48788bb39c11f3.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dp8hmks54a5pn"
path="res://.godot/imported/characters2.png-83b775982bc6db3ebbc0de7f992fcc55.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/character_select/characters2.png"
dest_files=["res://.godot/imported/characters2.png-83b775982bc6db3ebbc0de7f992fcc55.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c3vkon4wpa0kw"
path="res://.godot/imported/dwarf.png-af088bfc09b3ad67a721acb4793a2349.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/character_select/dwarf.png"
dest_files=["res://.godot/imported/dwarf.png-af088bfc09b3ad67a721acb4793a2349.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b3r1hyhgtv7sf"
path="res://.godot/imported/elf.png-d51237727e8d3e47b74708b9daddac02.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/character_select/elf.png"
dest_files=["res://.godot/imported/elf.png-d51237727e8d3e47b74708b9daddac02.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d3qv0erfu3xtl"
path="res://.godot/imported/wizard.png-88804da8863366e120bc2fc50367834e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/character_select/wizard.png"
dest_files=["res://.godot/imported/wizard.png-88804da8863366e120bc2fc50367834e.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://du3q527l26q1d"
path="res://.godot/imported/Spiderbat death.png-6a9578b9aaedfddc0a0b3de871bd4fd6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png"
dest_files=["res://.godot/imported/Spiderbat death.png-6a9578b9aaedfddc0a0b3de871bd4fd6.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cr5mc326wpob3"
path="res://.godot/imported/down_right.png-6d523ff59778709e04a9a3ea67a6b897.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/enemies/boss/SpiderBat/down_right.png"
dest_files=["res://.godot/imported/down_right.png-6d523ff59778709e04a9a3ea67a6b897.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://fevkanam8r2s"
path="res://.godot/imported/flying_down.png-376108b3e3498c8a5a7b1f340a520add.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/enemies/boss/SpiderBat/flying_down.png"
dest_files=["res://.godot/imported/flying_down.png-376108b3e3498c8a5a7b1f340a520add.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dsb72u2au1rkq"
path="res://.godot/imported/right.png-750fa141b376a0cc18fd164159e95203.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/gfx/enemies/boss/SpiderBat/right.png"
dest_files=["res://.godot/imported/right.png-750fa141b376a0cc18fd164159e95203.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

@@ -23,6 +23,7 @@ buses/default_bus_layout="uid://psistrevppd1"
[autoload] [autoload]
GameState="*res://scripts/game_state.gd"
NetworkManager="*res://scripts/network_manager.gd" NetworkManager="*res://scripts/network_manager.gd"
LogManager="*res://scripts/log_manager.gd" LogManager="*res://scripts/log_manager.gd"

View File

@@ -5,6 +5,7 @@
[ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_v2p0x"] [ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_v2p0x"]
[ext_resource type="AudioStream" uid="uid://uerx5rib87a6" path="res://assets/audio/sfx/weapons/bone_hit_wall_01.wav.mp3" id="4_ul7bj"] [ext_resource type="AudioStream" uid="uid://uerx5rib87a6" path="res://assets/audio/sfx/weapons/bone_hit_wall_01.wav.mp3" id="4_ul7bj"]
[ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="5_whqew"] [ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="5_whqew"]
[ext_resource type="Texture2D" uid="uid://7r43xnr812km" path="res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1.png" id="6_whfey"]
[sub_resource type="Animation" id="Animation_6bxep"] [sub_resource type="Animation" id="Animation_6bxep"]
length = 0.001 length = 0.001
@@ -46,12 +47,609 @@ _data = {
[sub_resource type="RectangleShape2D" id="RectangleShape2D_3jdng"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_3jdng"]
size = Vector2(12, 12) size = Vector2(12, 12)
[sub_resource type="Animation" id="Animation_whfey"]
length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [5]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(0, 0)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.44505897]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(0, 0)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_6tili"]
resource_name = "new_animation"
length = 0.26982978
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10101918, 0.1907425),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [5, 6, 7, 8]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.039631248, 0.04101525, 0.08727307, 0.100975364, 0.120369375, 0.13681212, 0.15641695, 0.19119969, 0.1968914, 0.26877576),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-8.385, -7.685), Vector2(-8.385, -7.685), Vector2(-8.385, -8.64), Vector2(-8.385, -8.64), Vector2(-12.263, -0.402), Vector2(-8.866, 9.504), Vector2(-2.31, 12.034), Vector2(1.625, 11.899), Vector2(8.165, 6.394), Vector2(8.165, 6.394), Vector2(8.165, 2.224)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.040804446, 0.08727307, 0.13688956, 0.1914105, 0.19731301, 0.26898658),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [0.44505897, 0.44505897, 0.44505897, -2.199114857512855, -3.6651914291880923, -3.6651914, -4.091051766674709]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.12163421, 0.1403958, 0.18803762, 0.2527546),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-32.05, 0), Vector2(-29.735, 12.04), Vector2(-20.673, 11.571), Vector2(-9.739, 9.129), Vector2(-9.254, 3.723)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_0lt3b"]
resource_name = "attack_down_left"
length = 0.27031633
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.03993078, 0.10122998, 0.19097532),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [250, 251, 252, 253]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.040279698, 0.0775326, 0.10221264, 0.19371508),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-6.435, -8), Vector2(-7.305, -7.955), Vector2(-11.835, -3.09), Vector2(-11.74, 4.555), Vector2(-6.375, 11.365)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.040279698, 0.077299766, 0.10174698, 0.19301659),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [0.4398229715025708, 0.3909537524467294, -0.24783675378319459, -1.0471975511965976, -2.0298179200694055]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00044363513, 0.07292879, 0.1013844, 0.1403958, 0.18827045),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1.5157166),
"update": 0,
"values": [Vector2(-25.874, -8.58), Vector2(-31.929, -4.05), Vector2(-33.3, 4.63), Vector2(-30.693, 8.711), Vector2(-27.919, 11.414)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_3ek3x"]
resource_name = "attack_down_right"
length = 0.26982978
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [40, 41, 42, 43]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.039631248, 0.04101525, 0.09401881, 0.09971053, 0.11003995, 0.12395305, 0.13681212, 0.15726018, 0.26898655),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-8.385, -7.685), Vector2(-8.385, -7.685), Vector2(-8.385, -8.64), Vector2(-8.385, -8.64), Vector2(-9.993, 7.435), Vector2(-4.231, 11.549), Vector2(2.785, 11.038), Vector2(9.175, 4.754), Vector2(8.95, 2.804), Vector2(8.95, 2.804)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.040804446, 0.093597196, 0.10076456, 0.10982916, 0.13710035, 0.15747097, 0.26919734),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [0.44505897, 0.44505897, 0.44505897, -1.6423548261266645, -2.1415189921970423, -3.743731245527837, -4.036946559862884, -4.036946559862884]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.12163421, 0.1403958, 0.18803762, 0.2527546),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-28.065, 4.33), Vector2(-17.055, 8.945), Vector2(-11.473, 9.766), Vector2(-9.739, 4.169), Vector2(-9.254, 2.583)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_3qvhs"]
resource_name = "attack_left"
length = 0.27031633
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.19097532),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [215, 216, 217, 218]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.040279698, 0.0775326, 0.10221264, 0.19371508),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(11.015, -5.165), Vector2(8.29, -7.325), Vector2(-1.53, -8.86), Vector2(-10.76, -1.445), Vector2(-10.47, 8.395)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.040279698, 0.077299766, 0.10174698, 0.19324942),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [1.722639971718403, 1.349139511791617, 0.49741883681838395, -0.3019419605950192, -1.4364059743913333]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00044363513, 0.07292879, 0.1013844, 0.1403958, 0.18803762),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-9.294, -5.345), Vector2(-19.749, -8.55), Vector2(-32.415, -5.325), Vector2(-32.588, 0.961), Vector2(-32.564, 7.954)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_58psj"]
resource_name = "attack_right"
length = 0.26982978
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [75, 76, 77, 77]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.04101525, 0.09401881, 0.09971053, 0.11003995, 0.12395305, 0.13681212, 0.15726018, 0.26898655),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-6.16, -7.685), Vector2(-6.16, -8.64), Vector2(-6.16, -8.64), Vector2(-9.993, 7.435), Vector2(-4.231, 11.549), Vector2(2.785, 11.038), Vector2(9.175, 4.754), Vector2(8.95, 2.804), Vector2(10.545, -0.231)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.040804446, 0.093597196, 0.10076456, 0.10982916, 0.13710035, 0.15747097, 0.26919734),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [0.44505897, 0.44505897, 0.44505897, -1.6423548261266645, -2.1415189921970423, -3.743731245527837, -4.036946559862884, -4.356341812977846]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.12163421, 0.1403958, 0.18803762, 0.26814333),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-28.065, 4.33), Vector2(-17.055, 8.945), Vector2(-11.473, 9.766), Vector2(-9.739, 4.169), Vector2(-5.094, 0.598)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_b4m4v"]
resource_name = "attack_up"
length = 0.27031633
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [145, 146, 147, 148]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.04068527, 0.070071764, 0.075806916, 0.07635719, 0.114121936, 0.14789572, 0.19222274, 0.22793879, 0.2691677),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(8.04, -7.355), Vector2(8.225, -8.145), Vector2(8.04, -9.18), Vector2(8.04, -9.18), Vector2(12, 0.49), Vector2(7.546, -5.247), Vector2(-4.945, -10.381), Vector2(-7.771, -5.086), Vector2(-8.07, -1.565), Vector2(-8.07, -1.565)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.07536515, 0.07555858, 0.07566956, 0.114121936, 0.1478039, 0.19203907, 0.22792329, 0.2691522),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [-0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -3.2288591161895095, -4.134684997974567, -4.717624968140673, -6.005677956112488, -6.283185307179586, -6.806784082777885, -6.806784082777885]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.09463231, 0.1403958, 0.18803762, 0.26837614),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-7, 2.075), Vector2(-19.748, -11.654), Vector2(-28.329, -7.576), Vector2(-30.434, -2.707)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.07590186, 0.07619396, 0.26969346),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 0,
"values": [Vector2(-1, 1), Vector2(-1, 1), Vector2(1, 1), Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_r6tgr"]
resource_name = "attack_up_left"
length = 0.27031633
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.19097532),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [180, 181, 182, 183]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.04068527, 0.0696061, 0.071848795, 0.07239907, 0.09828946, 0.10197981, 0.14789572, 0.19222274, 0.22793879, 0.2691677),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(3.075, -7.355), Vector2(3.385, -8.145), Vector2(4.125, -9.18), Vector2(8.04, -9.18), Vector2(7.46, -4.12), Vector2(-3.684, -5.822), Vector2(-5.898, -5.584), Vector2(-10.925, -0.251), Vector2(-12.136, 4.719), Vector2(-11.89, 4.77), Vector2(-11.89, 4.77)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.07140703, 0.07160046, 0.071711436, 0.09875512, 0.1478039, 0.19203907, 0.22792329, 0.2691522),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 0.8408964, 1, 1, 1, 1),
"update": 0,
"values": [-0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -3.2288591161895095, -4.714134309636685, -5.782275811857214, -6.806784082777885, -7.3303828583761845, -7.3303828583761845, -7.3303828583761845]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00021080452, 0.07292879, 0.09486514, 0.1403958, 0.18803762, 0.26837614),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-22.394, 0), Vector2(-10.474, -5.42), Vector2(-20.235, -7.885), Vector2(-32.358, -3.184), Vector2(-34.939, 3.129), Vector2(-34.469, 4.153)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.07194374, 0.07223584, 0.26969346),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 0,
"values": [Vector2(-1, 1), Vector2(-1, 1), Vector2(1, 1), Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_masq6"]
resource_name = "attack_up_right"
length = 0.27031633
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("PlayerTest:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [110, 111, 112, 113]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Sprite2D:position")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.06983893, 0.09338019, 0.09405232, 0.10155232, 0.107935, 0.17927958, 0.27015913),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(3.885, -7.355), Vector2(3.885, -8.145), Vector2(3.885, -8.145), Vector2(4.89, 7.675), Vector2(4.89, 7.675), Vector2(4.89, 7.675), Vector2(11.41, 4.38), Vector2(11.52, -0.24), Vector2(8.145, -7.405), Vector2(8.145, -7.405)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Sprite2D:rotation")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.06982343, 0.093364686, 0.09403682, 0.10157588, 0.10791949, 0.17904675, 0.26969346),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [-0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -3.2288591161895095, -3.2288591161895095, -3.2288591161895095, -3.9217548292312583, -4.508185457901353, -5.040510879759624, -5.040510879759624]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("DamageArea:position")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.1403958, 0.18803762, 0.26837614),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-8.56, 5.56), Vector2(-8.968, -4.474), Vector2(-11.929, -10.041), Vector2(-12.239, -8.572)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Sprite2D:scale")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.06944155, 0.0697763, 0.26969346),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 0,
"values": [Vector2(-1, 1), Vector2(-1, 1), Vector2(1, 1), Vector2(1, 1)]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_3ek3x"]
_data = {
&"RESET": SubResource("Animation_whfey"),
&"attack_down": SubResource("Animation_6tili"),
&"attack_down_left": SubResource("Animation_0lt3b"),
&"attack_down_right": SubResource("Animation_3ek3x"),
&"attack_left": SubResource("Animation_3qvhs"),
&"attack_right": SubResource("Animation_58psj"),
&"attack_up": SubResource("Animation_b4m4v"),
&"attack_up_left": SubResource("Animation_r6tgr"),
&"attack_up_right": SubResource("Animation_masq6")
}
[node name="AxeSwing" type="Node2D" unique_id=1568208090] [node name="AxeSwing" type="Node2D" unique_id=1568208090]
z_index = 10 z_index = 10
y_sort_enabled = true y_sort_enabled = true
script = ExtResource("1_xo3v0") script = ExtResource("1_xo3v0")
[node name="PlayerTest" type="Sprite2D" parent="." unique_id=1790922634]
visible = false
texture = ExtResource("6_whfey")
hframes = 35
vframes = 8
frame = 5
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=461038063] [node name="Sprite2D" type="Sprite2D" parent="." unique_id=461038063]
z_index = 1
rotation = 0.44505897
texture = ExtResource("2_hb10f") texture = ExtResource("2_hb10f")
hframes = 20 hframes = 20
vframes = 14 vframes = 14
@@ -66,10 +664,11 @@ pitch_scale = 0.74
autoplay = true autoplay = true
[node name="DamageArea" type="Area2D" parent="." unique_id=985585639] [node name="DamageArea" type="Area2D" parent="." unique_id=985585639]
collision_layer = 0 collision_layer = 4
collision_mask = 75 collision_mask = 3
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageArea" unique_id=805714782] [node name="CollisionShape2D" type="CollisionShape2D" parent="DamageArea" unique_id=805714782]
position = Vector2(20, 0)
shape = SubResource("RectangleShape2D_3jdng") shape = SubResource("RectangleShape2D_3jdng")
debug_color = Color(0.7, 0, 0.18232, 0.42) debug_color = Color(0.7, 0, 0.18232, 0.42)
@@ -85,5 +684,8 @@ volume_db = -5.622
pitch_scale = 1.43 pitch_scale = 1.43
max_polyphony = 4 max_polyphony = 4
[node name="SwingAnimation" type="AnimationPlayer" parent="." unique_id=1169276342]
libraries/ = SubResource("AnimationLibrary_3ek3x")
[connection signal="area_entered" from="DamageArea" to="." method="_on_damage_area_area_entered"] [connection signal="area_entered" from="DamageArea" to="." method="_on_damage_area_area_entered"]
[connection signal="body_entered" from="DamageArea" to="." method="_on_damage_area_body_entered"] [connection signal="body_entered" from="DamageArea" to="." method="_on_damage_area_body_entered"]

View File

@@ -0,0 +1,43 @@
[gd_scene format=3 uid="uid://hldlevntj8c2"]
[ext_resource type="Script" uid="uid://ddqd1nlmsb8k6" path="res://scripts/attack_punch.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_swosh"]
[ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="4_impact"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_punch"]
[node name="AttackPunch" type="Node2D" unique_id=6975553]
z_index = 4
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1707910142]
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 108
[node name="Area2D" type="Area2D" parent="." unique_id=1216417398]
collision_layer = 4
collision_mask = 3
[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=689895866]
shape = SubResource("RectangleShape2D_punch")
[node name="SfxSwosh" type="AudioStreamPlayer2D" parent="." unique_id=1177568406]
stream = ExtResource("3_swosh")
pitch_scale = 0.67
max_distance = 983.0
attenuation = 7.999991
panning_strength = 1.1
bus = &"Sfx"
[node name="SfxImpact" type="AudioStreamPlayer2D" parent="." unique_id=1273542180]
stream = ExtResource("4_impact")
volume_db = -5.622
pitch_scale = 1.43
max_distance = 983.0
attenuation = 7.999991
max_polyphony = 4
panning_strength = 1.16
bus = &"Sfx"

View File

@@ -43,5 +43,6 @@ shape = SubResource("RectangleShape2D_frost")
stream = ExtResource("3_y7fsv") stream = ExtResource("3_y7fsv")
max_distance = 1456.0 max_distance = 1456.0
attenuation = 5.4641595 attenuation = 5.4641595
max_polyphony = 3
panning_strength = 1.06 panning_strength = 1.06
bus = &"Sfx" bus = &"Sfx"

View File

@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://damage_effect_arrow"]
[ext_resource type="Script" path="res://scripts/damage_effect_arrow.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[node name="DamageEffectArrow" type="Node2D"]
z_index = 5
script = ExtResource("1_script")
[node name="FxSprite" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 335

View File

@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://damage_effect_axe"]
[ext_resource type="Script" path="res://scripts/damage_effect_axe.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[node name="DamageEffectAxe" type="Node2D"]
z_index = 5
script = ExtResource("1_script")
[node name="FxSprite" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 1158

View File

@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://damage_effect_bite"]
[ext_resource type="Script" path="res://scripts/damage_effect_bite.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[node name="DamageEffectBite" type="Node2D"]
z_index = 5
script = ExtResource("1_script")
[node name="FxSprite" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 148

View File

@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://damage_effect_punch"]
[ext_resource type="Script" path="res://scripts/damage_effect_punch.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[node name="DamageEffectPunch" type="Node2D"]
z_index = 5
script = ExtResource("1_script")
[node name="FxSprite" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 451

View File

@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://damage_effect_slash"]
[ext_resource type="Script" path="res://scripts/damage_effect_slash.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[node name="DamageEffectSlash" type="Node2D"]
z_index = 5
script = ExtResource("1_script")
[node name="FxSprite" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 948

View File

@@ -114,10 +114,10 @@ color = Color(0.69140625, 0.69140625, 0.69140625, 1)
[node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465] [node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465]
stream = ExtResource("6_6c6v5") stream = ExtResource("6_6c6v5")
volume_db = -20.411 volume_db = -80.0
autoplay = true autoplay = true
bus = &"Sfx" bus = &"Sfx"
[node name="BgMusic" type="AudioStreamPlayer" parent="." unique_id=925983703] [node name="BgMusic" type="AudioStreamPlayer" parent="." unique_id=925983703]
stream = ExtResource("8_pdbwf") stream = ExtResource("8_pdbwf")
volume_db = -15.864 volume_db = -80.0

View File

@@ -10,6 +10,10 @@ layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 2.0
offset_top = -9.0
offset_right = 2.0
offset_bottom = -9.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
@@ -131,6 +135,10 @@ autowrap_mode = 2
custom_minimum_size = Vector2(0, 20) custom_minimum_size = Vector2(0, 20)
layout_mode = 2 layout_mode = 2
[node name="SelectRaceButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=1061067009]
layout_mode = 2
text = "Choose race"
[node name="HostButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=268532531] [node name="HostButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=268532531]
layout_mode = 2 layout_mode = 2
text = "Host Game" text = "Host Game"

View File

@@ -702,12 +702,6 @@ collision_mask = 3
shape = SubResource("CircleShape2D_2") shape = SubResource("CircleShape2D_2")
debug_color = Color(0.70196074, 0.6126261, 0.19635464, 0.41960785) debug_color = Color(0.70196074, 0.6126261, 0.19635464, 0.41960785)
[node name="InteractionIndicator" type="Sprite2D" parent="." unique_id=1661043470]
visible = false
modulate = Color(1, 1, 0, 0.5)
position = Vector2(0, -12)
scale = Vector2(4, 4)
[node name="Label" type="Label" parent="." unique_id=227628720] [node name="Label" type="Label" parent="." unique_id=227628720]
offset_left = -10.0 offset_left = -10.0
offset_top = -15.0 offset_top = -15.0
@@ -715,6 +709,12 @@ offset_right = 10.0
offset_bottom = -9.0 offset_bottom = -9.0
horizontal_alignment = 1 horizontal_alignment = 1
[node name="InteractionIndicator" type="Sprite2D" parent="." unique_id=1661043470]
visible = false
modulate = Color(1, 1, 0, 0.5)
position = Vector2(0, -12)
scale = Vector2(4, 4)
[node name="SfxWalk" type="AudioStreamPlayer2D" parent="." unique_id=1693322702] [node name="SfxWalk" type="AudioStreamPlayer2D" parent="." unique_id=1693322702]
stream = SubResource("AudioStreamRandomizer_l71n6") stream = SubResource("AudioStreamRandomizer_l71n6")
volume_db = -18.527 volume_db = -18.527

View File

@@ -0,0 +1,130 @@
[gd_scene format=3 uid="uid://ljvf241rr1qj"]
[ext_resource type="Texture2D" uid="uid://dp8hmks54a5pn" path="res://assets/gfx/character_select/characters2.png" id="1_f45sh"]
[ext_resource type="Texture2D" uid="uid://cj3adlbm2ksl4" path="res://assets/gfx/character_select/characters.png" id="1_fiono"]
[ext_resource type="Script" uid="uid://cwbrfwrwt3krh" path="res://scripts/select_class.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://c3vkon4wpa0kw" path="res://assets/gfx/character_select/dwarf.png" id="2_0qyep"]
[ext_resource type="Texture2D" uid="uid://b3r1hyhgtv7sf" path="res://assets/gfx/character_select/elf.png" id="3_ewbj8"]
[ext_resource type="FontFile" uid="uid://bajcvmidrnc33" path="res://assets/fonts/standard_font.png" id="3_f45sh"]
[ext_resource type="Texture2D" uid="uid://d3qv0erfu3xtl" path="res://assets/gfx/character_select/wizard.png" id="4_f45sh"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_bsj10"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_8nlan"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4b24w"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1si1m"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gkv83"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3328y"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q1cbd"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_yyf7y"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_7d5tl"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1itgt"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_8xviw"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_oflmk"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_rl1cb"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_x8w6g"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_uihst"]
[node name="SelectClass" type="Node2D" unique_id=126568820]
script = ExtResource("1_script")
[node name="Characters2" type="Sprite2D" parent="." unique_id=2011779728]
texture = ExtResource("1_f45sh")
[node name="Characters" type="Sprite2D" parent="Characters2" unique_id=1197243452]
modulate = Color(0.9472463, 0.94050175, 1, 1)
texture = ExtResource("1_fiono")
[node name="Dwarf" type="Sprite2D" parent="." unique_id=1905014075]
visible = false
texture = ExtResource("2_0qyep")
[node name="Label" type="Label" parent="Dwarf" unique_id=1691050946]
offset_left = -88.0
offset_top = 82.0
offset_right = -48.0
offset_bottom = 105.0
theme_override_fonts/font = ExtResource("3_f45sh")
theme_override_font_sizes/font_size = 8
text = "DWARF"
[node name="Elf" type="Sprite2D" parent="." unique_id=172131227]
visible = false
texture = ExtResource("3_ewbj8")
[node name="Label" type="Label" parent="Elf" unique_id=1853931362]
offset_left = 48.0
offset_top = 82.0
offset_right = 88.0
offset_bottom = 105.0
theme_override_fonts/font = ExtResource("3_f45sh")
theme_override_font_sizes/font_size = 8
text = "ELF"
[node name="Wizard" type="Sprite2D" parent="." unique_id=718483736]
visible = false
texture = ExtResource("4_f45sh")
[node name="Label" type="Label" parent="Wizard" unique_id=244634907]
offset_left = -26.0
offset_top = 85.0
offset_right = 22.0
offset_bottom = 108.0
theme_override_fonts/font = ExtResource("3_f45sh")
theme_override_font_sizes/font_size = 8
text = "WIZARD"
[node name="Control" type="Control" parent="." unique_id=952415672]
layout_mode = 3
anchors_preset = 0
offset_right = 40.0
offset_bottom = 40.0
[node name="ButtonDwarf" type="Button" parent="Control" unique_id=70318818]
layout_mode = 0
offset_left = -116.0
offset_top = -27.0
offset_right = -44.0
offset_bottom = 90.0
theme_override_styles/normal = SubResource("StyleBoxEmpty_bsj10")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_8nlan")
theme_override_styles/hover = SubResource("StyleBoxEmpty_4b24w")
theme_override_styles/disabled = SubResource("StyleBoxEmpty_1si1m")
theme_override_styles/focus = SubResource("StyleBoxEmpty_gkv83")
[node name="ButtonElf" type="Button" parent="Control" unique_id=860222517]
layout_mode = 0
offset_left = 37.0
offset_top = -32.0
offset_right = 126.0
offset_bottom = 90.0
theme_override_styles/normal = SubResource("StyleBoxEmpty_3328y")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_q1cbd")
theme_override_styles/hover = SubResource("StyleBoxEmpty_yyf7y")
theme_override_styles/disabled = SubResource("StyleBoxEmpty_7d5tl")
theme_override_styles/focus = SubResource("StyleBoxEmpty_1itgt")
[node name="ButtonWizard" type="Button" parent="Control" unique_id=170883452]
layout_mode = 0
offset_left = -44.0
offset_top = -65.0
offset_right = 37.0
offset_bottom = 94.0
theme_override_styles/normal = SubResource("StyleBoxEmpty_8xviw")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_oflmk")
theme_override_styles/hover = SubResource("StyleBoxEmpty_rl1cb")
theme_override_styles/disabled = SubResource("StyleBoxEmpty_x8w6g")
theme_override_styles/focus = SubResource("StyleBoxEmpty_uihst")

View File

@@ -2,12 +2,14 @@ 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 enemies/players var stick_duration = 3.0 # How long the arrow stays stuck to players
var stick_duration_enemy = 1.2 # Shorter when stuck to enemy - fade out faster
var wall_stick_duration = 30.0 # Much longer duration for wall-stuck arrows (30 seconds) 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 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 stuck_to_wall = false # Track if stuck to wall (vs enemy/player)
var stuck_to_enemy = false # Track if stuck to enemy (use shorter duration)
var can_be_collected = false # True after delay on wall var can_be_collected = false # True after delay on wall
var shooter_can_collect = false # Shooter can collect after 0.2 seconds var shooter_can_collect = false # Shooter can collect after 0.2 seconds
var shooter_collection_delay = 0.2 # Fast pickup for shooter var shooter_collection_delay = 0.2 # Fast pickup for shooter
@@ -91,11 +93,9 @@ func _process(delta: float) -> void:
# Track flight time and "land" arrow after max_flight_duration # Track flight time and "land" arrow after max_flight_duration
flight_timer += delta flight_timer += delta
if flight_timer >= max_flight_duration: if flight_timer >= max_flight_duration:
# Arrow has flown for max duration - "land" it (stop and stick to ground) # Defer landing so any body_entered from this frame's physics runs first.
can_deal_damage = false # Otherwise we can set can_deal_damage=false before the overlap is processed.
$SfxLandsOnGround.play() call_deferred("_land_arrow_from_flight")
_stick_to_wall() # Land on ground
print("Arrow landed after flying for ", flight_timer, " seconds")
return # Exit early to prevent further movement this frame return # Exit early to prevent further movement this frame
# Continue flying # Continue flying
@@ -115,8 +115,14 @@ func _process(delta: float) -> void:
if stick_timer >= others_collection_delay and not can_be_collected: if stick_timer >= others_collection_delay and not can_be_collected:
can_be_collected = true can_be_collected = true
# Use appropriate duration based on what it's stuck to # Use appropriate duration based on what it's stuck to (enemy = faster fade)
var duration = wall_stick_duration if stuck_to_wall else stick_duration var duration: float
if stuck_to_wall:
duration = wall_stick_duration
elif stuck_to_enemy:
duration = stick_duration_enemy
else:
duration = stick_duration
if stick_timer >= duration: if stick_timer >= duration:
# Start fading out after it sticks # Start fading out after it sticks
@@ -157,29 +163,9 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
if body in hit_targets: if body in hit_targets:
return return
# Deal damage to players # Ignore other players - arrow passes through (no friendly fire)
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 hit_targets[body] = true
play_impact()
# CRITICAL: Stick to target on ALL clients FIRST (before damage check)
# This ensures the arrow stops on all clients, not just the authority
_stick_to_target(body)
# CRITICAL: Only the projectile owner (authority) should deal damage to players
if player_owner and player_owner.is_multiplayer_authority():
var attacker_pos = player_owner.global_position if player_owner else global_position
var player_peer_id = body.get_multiplayer_authority()
if player_peer_id != 0:
if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id():
body.rpc_take_damage(20.0, attacker_pos) # TODO: Get actual damage from player
else:
body.rpc_take_damage.rpc_id(player_peer_id, 20.0, attacker_pos)
else:
body.rpc_take_damage.rpc(20.0, attacker_pos)
return return
# Deal damage to enemies # Deal damage to enemies
@@ -205,13 +191,10 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
# MISS - arrow passes through enemy and continues flying! # 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
# Add to hit_targets so we don't check this enemy again
hit_targets[body] = true hit_targets[body] = true
# Sync miss to all clients - arrow continues flying # Sync miss to all clients - arrow continues flying
# CRITICAL: Validate body is still valid and use name instead of path if is_inside_tree() and is_instance_valid(self) and is_instance_valid(body) and body.is_inside_tree():
if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree(): _sync_arrow_miss_via_gameworld(name, body.name)
_sync_arrow_miss.rpc(body.name)
# Don't stick to target - let arrow continue flying
return return
# Check enemy dodge chance (based on enemy's DEX stat) # Check enemy dodge chance (based on enemy's DEX stat)
@@ -225,13 +208,10 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
# DODGE - arrow passes through enemy and continues flying! # DODGE - 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, false, true) # is_dodged = true 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 hit_targets[body] = true
# Sync dodge to all clients - arrow continues flying # Sync dodge to all clients - arrow continues flying
# CRITICAL: Validate body is still valid and use name instead of path if is_inside_tree() and is_instance_valid(self) and is_instance_valid(body) and body.is_inside_tree():
if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree(): _sync_arrow_dodge_via_gameworld(name, body.name)
_sync_arrow_dodge.rpc(body.name)
# Don't stick to target - let arrow continue flying
print(body.name, " DODGED arrow! Arrow continues flying...") print(body.name, " DODGED arrow! Arrow continues flying...")
return return
@@ -250,21 +230,34 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
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 # Sync hit to all clients - arrow sticks
# CRITICAL: Validate body is still valid and use name instead of path # CRITICAL: Route through game_world to avoid node path issues
if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree(): if is_inside_tree() and is_instance_valid(self) and is_instance_valid(body) and body.is_inside_tree():
_sync_arrow_hit.rpc(body.name) var arrow_name = name
_sync_arrow_hit_via_gameworld(arrow_name, body.name)
_stick_to_target(body) # Arrow hit effect (sync so all clients see it)
var hit_pos = body.global_position
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_sync_arrow_hit_effect"):
if multiplayer.is_server():
gw._sync_arrow_hit_effect(hit_pos.x, hit_pos.y)
if gw.has_method("_rpc_to_ready_peers"):
gw._rpc_to_ready_peers("_sync_arrow_hit_effect", [hit_pos.x, hit_pos.y])
else:
gw._request_arrow_hit_effect.rpc_id(1, hit_pos.x, hit_pos.y)
_stick_to_target(body, true)
return return
# Hit wall or other object # Hit wall or other object
$SfxImpactWall.play() $SfxImpactWall.play()
_stick_to_wall() _stick_to_wall()
func _stick_to_target(target: Node2D): func _stick_to_target(target: Node2D, to_enemy: bool = false):
# Stop the arrow # Stop the arrow
velocity = Vector2.ZERO velocity = Vector2.ZERO
is_stuck = true is_stuck = true
stuck_to_enemy = to_enemy
stick_timer = 0.0 stick_timer = 0.0
arrow_area.set_deferred("monitoring", false) arrow_area.set_deferred("monitoring", false)
@@ -280,6 +273,15 @@ func _stick_to_target(target: Node2D):
self.set_deferred("global_position", stick_position) self.set_deferred("global_position", stick_position)
self.set_deferred("global_rotation", global_rot) self.set_deferred("global_rotation", global_rot)
func _land_arrow_from_flight():
# Called deferred when flight_timer expired - ensures body_entered from this frame ran first
if is_stuck:
return
can_deal_damage = false
$SfxLandsOnGround.play()
_stick_to_wall()
print("Arrow landed after flying for ", flight_timer, " seconds")
func _stick_to_wall(): func _stick_to_wall():
# Stop the arrow # Stop the arrow
velocity = Vector2.ZERO velocity = Vector2.ZERO
@@ -297,69 +299,43 @@ func _sync_arrow_collected_via_gameworld(arrow_name: String):
if gw and gw.has_method("_sync_arrow_collected") and multiplayer.has_multiplayer_peer(): if gw and gw.has_method("_sync_arrow_collected") and multiplayer.has_multiplayer_peer():
gw._sync_arrow_collected.rpc(arrow_name) gw._sync_arrow_collected.rpc(arrow_name)
@rpc("any_peer", "call_local", "reliable") func _sync_arrow_hit_via_gameworld(arrow_name: String, target_name: String):
func _sync_arrow_hit(target_name: String): # Route arrow hit sync through game_world to avoid node path issues
# Authority determined arrow HIT enemy - stick to it on all clients if arrow_name.is_empty() or target_name.is_empty():
# CRITICAL: Validate arrow is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return return
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_sync_arrow_hit") and multiplayer.has_multiplayer_peer():
gw._sync_arrow_hit.rpc(arrow_name, target_name)
# Find target by name in Entities node func _sync_arrow_miss_via_gameworld(arrow_name: String, target_name: String):
var target = null # Route arrow miss sync through game_world to avoid node path issues
var game_world = get_tree().get_first_node_in_group("game_world") if arrow_name.is_empty() or target_name.is_empty():
if game_world: return
var entities_node = game_world.get_node_or_null("Entities") var gw = get_tree().get_first_node_in_group("game_world")
if entities_node: if gw and gw.has_method("_sync_arrow_miss") and multiplayer.has_multiplayer_peer():
target = entities_node.get_node_or_null(target_name) gw._sync_arrow_miss.rpc(arrow_name, target_name)
if not target: func _sync_arrow_dodge_via_gameworld(arrow_name: String, target_name: String):
print("WARNING: Arrow hit target not found: ", target_name) # Route arrow dodge sync through game_world to avoid node path issues
if arrow_name.is_empty() or target_name.is_empty():
return
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_sync_arrow_dodge") and multiplayer.has_multiplayer_peer():
gw._sync_arrow_dodge.rpc(arrow_name, target_name)
# Helper method for game_world to process arrow hit sync
func _process_arrow_hit_sync(target: Node):
# Process arrow hit sync from game_world
if not is_instance_valid(self) or not is_inside_tree():
return return
if target not in hit_targets: if target not in hit_targets:
hit_targets[target] = true hit_targets[target] = true
play_impact() play_impact()
_stick_to_target(target) var is_enemy = target.is_in_group("enemy") if target else false
_stick_to_target(target, is_enemy)
print("Arrow synced as HIT to: ", target.name) print("Arrow synced as HIT to: ", target.name)
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_miss(target_name: String):
# Authority determined arrow MISSED enemy - continues flying on all clients
# CRITICAL: Validate arrow is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
# Find target by name in Entities node
var target = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
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_name: String):
# Authority determined enemy DODGED arrow - continues flying on all clients
# CRITICAL: Validate arrow is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
# Find target by name in Entities node
var target = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
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(): func _enable_collection_area():
# Create an Area2D for collecting the arrow # Create an Area2D for collecting the arrow
if collection_area: if collection_area:

View File

@@ -1,64 +1,157 @@
extends Node2D extends Node2D
var direction := Vector2.ZERO # Default direction # Axe Swing - stays on player, plays $SwingAnimation by direction (attack_down, attack_right, etc.).
var fade_delay := 0.14 # When to start fading (mid-move) # Uses equipped axe texture/frame. On hit: deal damage, spawn damage_effect_axe.
var move_duration := 0.2 # Slash exists for 0.3 seconds # Duration ~0.27s to match player AXE/SWORD animation.
var fade_duration := 0.06 # Time to fade out
var stretch_amount := Vector2(1, 1.4) # How much to stretch the sprite const LIFETIME: float = 0.27
var slash_amount = 8 const DIR_ANIMATIONS: Array = [
var initiated_by: Node2D = null "attack_right", # 0: right
"attack_down_right", # 1
"attack_down", # 2
"attack_down_left", # 3
"attack_left", # 4
"attack_up_left", # 5
"attack_up", # 6
"attack_up_right" # 7
]
@export var damage: float = 20.0
var elapsed_time: float = 0.0
var player_owner: Node = null
var hit_targets: Dictionary = {}
var damage_effect_axe_scene: PackedScene = preload("res://scenes/damage_effect_axe.tscn")
@onready var sprite: Sprite2D = $Sprite2D
@onready var hit_area: Area2D = $DamageArea
@onready var swing_animation: AnimationPlayer = $SwingAnimation
# Called when the node enters the scene tree for the first time.
func _ready() -> void: func _ready() -> void:
call_deferred("_initialize_swing") if hit_area:
pass # Replace with function body. if not hit_area.body_entered.is_connected(_on_damage_area_body_entered):
hit_area.body_entered.connect(_on_damage_area_body_entered)
if has_node("AttackSwosh"):
$AttackSwosh.play()
func _initialize_swing(): func setup(attack_direction: Vector2, owner_player: Node, _arc_direction: float = 1.0, axe_item: Item = null) -> void:
var tween = create_tween() player_owner = owner_player
var move_target = global_position + (direction.normalized() * slash_amount) # Moves in given direction if owner_player and owner_player.character_stats:
tween.set_trans(Tween.TRANS_CUBIC) # Smooth acceleration & deceleration damage = owner_player.character_stats.damage
tween.set_ease(Tween.EASE_OUT) # Fast start, then slows down # Use equipped axe texture and frame
tween.tween_property(self, "global_position", move_target, move_duration) if axe_item and sprite:
' var tex = load(axe_item.spritePath) as Texture2D
# Create stretch tween (grow and shrink slightly) if tex:
var stretch_tween = create_tween() sprite.texture = tex
stretch_tween.set_trans(Tween.TRANS_CUBIC) sprite.hframes = axe_item.spriteFrames.x if axe_item.spriteFrames.x > 0 else 20
stretch_tween.set_ease(Tween.EASE_OUT) sprite.vframes = axe_item.spriteFrames.y if axe_item.spriteFrames.y > 0 else 14
stretch_tween.tween_property($Sprite2D, "scale", Vector2.ONE, move_duration / 2) # start normal sprite.frame = axe_item.spriteFrame
stretch_tween.tween_property($Sprite2D, "scale", stretch_amount, move_duration / 2) # Pick direction animation: 8 sectors by angle
' var dir_norm = attack_direction.normalized()
var angle = dir_norm.angle()
var sector = int(round(angle / (TAU / 8.0))) % 8
if sector < 0:
sector += 8
var anim_name = DIR_ANIMATIONS[sector] if sector < DIR_ANIMATIONS.size() else "attack_down"
if swing_animation and swing_animation.has_animation(anim_name):
swing_animation.play(anim_name)
# Wait until mid-move to start fade func _process(delta: float) -> void:
await get_tree().create_timer(fade_delay).timeout elapsed_time += delta
if player_owner and is_instance_valid(player_owner):
# Start fade-out effect global_position = player_owner.global_position
var fade_tween = create_tween() if elapsed_time >= LIFETIME:
fade_tween.tween_property($Sprite2D, "modulate:a", 0.0, fade_duration) # Fade to transparent
await fade_tween.finished
queue_free() queue_free()
pass
func _on_damage_area_body_entered(body: Node2D) -> void: func _on_damage_area_body_entered(body: Node2D) -> void:
if body.get_parent() == initiated_by or body == initiated_by: if body == player_owner:
return return
if body.get_parent() is CharacterBody2D and body.get_parent().stats.is_invulnerable == false and body.get_parent().stats.hp > 0: # hit an enemy if body in hit_targets:
$MeleeImpact.play()
body.take_damage(self, initiated_by)
pass
else:
$MeleeImpactWall.play()
pass
pass # Replace with function body.
func _on_damage_area_area_entered(body: Area2D) -> void:
if body.get_parent() == initiated_by:
return return
if body.get_parent() is CharacterBody2D and body.get_parent().stats.is_invulnerable == false and body.get_parent().stats.hp > 0: # hit an enemy hit_targets[body] = true
$MeleeImpact.play()
body.get_parent().take_damage(self, initiated_by) # Only authority deals damage
pass if player_owner and not player_owner.is_multiplayer_authority():
return
var attacker_pos = player_owner.global_position if player_owner and is_instance_valid(player_owner) else global_position
# Ignore other players
if body.is_in_group("player") and body.has_method("take_damage"):
return
# Enemy: deal damage via game_world, spawn axe hit effect
if body.is_in_group("enemy") and body.has_method("rpc_take_damage"):
var game_world = get_tree().get_first_node_in_group("game_world")
var enemy_name = body.name
var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1
if game_world and game_world.has_method("_request_enemy_damage"):
if multiplayer.is_server():
game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false)
else: else:
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false)
else:
var eid = body.get_multiplayer_authority()
if eid != 0:
if multiplayer.is_server() and eid == multiplayer.get_unique_id():
body.rpc_take_damage(damage, attacker_pos, false)
else:
body.rpc_take_damage.rpc_id(eid, damage, attacker_pos, false)
else:
body.rpc_take_damage.rpc(damage, attacker_pos, false)
if has_node("MeleeImpact"):
$MeleeImpact.play()
_spawn_axe_hit_effect(body.global_position)
return
# Boxes / interactables with health
if "health" in body:
if has_node("MeleeImpact"):
$MeleeImpact.play()
body.health -= damage
if body.health <= 0 and body.has_method("_break_into_pieces"):
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and multiplayer.has_multiplayer_peer():
var obj_name = body.name
if body.has_meta("object_index"):
var idx = body.get_meta("object_index")
if idx >= 0:
obj_name = "InteractableObject_%d" % idx
if multiplayer.is_server():
game_world._rpc_to_ready_peers("_sync_object_break", [obj_name])
else:
game_world._sync_object_break.rpc_id(1, obj_name)
body._break_into_pieces()
_spawn_axe_hit_effect(body.global_position)
return
if has_node("MeleeImpactWall"):
$MeleeImpactWall.play() $MeleeImpactWall.play()
pass
pass # Replace with function body. # Knockback for CharacterBody2D (e.g. corpses)
if body is CharacterBody2D:
var knockback_dir = (body.global_position - global_position).normalized()
body.velocity = knockback_dir * 200.0
func _spawn_axe_hit_effect(at_position: Vector2) -> void:
if multiplayer.has_multiplayer_peer() and player_owner and player_owner.is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_axe_hit_effect"):
if multiplayer.is_server():
game_world._sync_axe_hit_effect(at_position.x, at_position.y)
if game_world.has_method("_rpc_to_ready_peers"):
game_world._rpc_to_ready_peers("_sync_axe_hit_effect", [at_position.x, at_position.y])
else:
game_world._request_axe_hit_effect.rpc_id(1, at_position.x, at_position.y)
return
if not damage_effect_axe_scene:
return
var effect = damage_effect_axe_scene.instantiate()
var parent = get_parent()
if parent:
parent.add_child(effect)
else:
get_tree().current_scene.add_child(effect)
effect.global_position = at_position
if effect.has_method("setup"):
effect.setup(at_position)

View File

@@ -434,6 +434,15 @@ func _spawn_explosion_tile_particles():
spr.texture = tex spr.texture = tex
spr.region_enabled = true spr.region_enabled = true
spr.region_rect = regions[i] spr.region_rect = regions[i]
# CRITICAL: Apply level's material and colorization to tile particles
# Get the material from the tilemap layer and duplicate it
# Duplicating ShaderMaterial copies all shader parameters (colorization, tint, ambient, etc.)
if layer.material and layer.material is ShaderMaterial:
var layer_material = layer.material as ShaderMaterial
var particle_material = layer_material.duplicate() as ShaderMaterial
spr.material = particle_material
p.global_position = world p.global_position = world
var speed = randf_range(280.0, 420.0) # Much faster - fly around more 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)) var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))

135
src/scripts/attack_punch.gd Normal file
View File

@@ -0,0 +1,135 @@
extends Node2D
# Unarmed punch - appears in front of player, low STR-based damage.
# Uses shade_spell_effects.png frames 108-113 for the punch, spawns damage_effect_punch on hit.
@export var damage: float = 3.0
@export var lifetime: float = 0.25
@export var punch_distance: float = 12.0 # Closer than sword (less range)
const PUNCH_FRAMES: Array = [108, 109, 110, 111, 112, 113]
const FRAME_DURATION: float = 0.04
var punch_direction: Vector2 = Vector2.RIGHT
var player_owner: Node = null
var hit_targets: Dictionary = {}
var elapsed: float = 0.0
var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect_punch.tscn")
@onready var sprite: Sprite2D = $Sprite2D
@onready var hit_area: Area2D = $Area2D
func _ready() -> void:
if has_node("SfxSwosh"):
$SfxSwosh.play()
if hit_area and not hit_area.body_entered.is_connected(_on_body_entered):
hit_area.body_entered.connect(_on_body_entered)
if sprite and PUNCH_FRAMES.size() > 0:
sprite.frame = PUNCH_FRAMES[0]
func setup(direction: Vector2, owner_player: Node, damage_value: float = 3.0) -> void:
punch_direction = direction.normalized()
player_owner = owner_player
damage = damage_value
rotation = punch_direction.angle()
# Position in front of player
if owner_player and is_instance_valid(owner_player):
global_position = owner_player.global_position + punch_direction * punch_distance
func _process(delta: float) -> void:
elapsed += delta
# Keep punch in front of player
if player_owner and is_instance_valid(player_owner):
global_position = player_owner.global_position + punch_direction * punch_distance
# Animate frames 108-113
if sprite and PUNCH_FRAMES.size() > 0:
var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1)
sprite.frame = PUNCH_FRAMES[frame_idx]
if elapsed >= lifetime:
hit_area.set_deferred("monitoring", false)
queue_free()
func _on_body_entered(body: Node2D) -> void:
if body == player_owner:
return
if body in hit_targets:
return
hit_targets[body] = true
# Only authority deals damage (same as sword_projectile)
if player_owner and not player_owner.is_multiplayer_authority():
return
var attacker_pos = player_owner.global_position if player_owner and is_instance_valid(player_owner) else global_position
# Ignore other players (no friendly fire) - pass through
if body.is_in_group("player") and body.has_method("rpc_take_damage"):
return
# Enemy: apply damage (enemy's take_damage/rpc_take_damage applies defence)
if body.is_in_group("enemy") and body.has_method("rpc_take_damage"):
var game_world = get_tree().get_first_node_in_group("game_world")
var enemy_name = body.name
var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1
if game_world and game_world.has_method("_request_enemy_damage"):
if multiplayer.is_server():
game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false)
else:
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false)
else:
var enemy_peer_id = body.get_multiplayer_authority()
if enemy_peer_id != 0:
if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id():
body.rpc_take_damage(damage, attacker_pos, false)
else:
body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, false)
else:
body.rpc_take_damage.rpc(damage, attacker_pos, false)
if has_node("SfxImpact"):
$SfxImpact.play()
_spawn_damage_effect(body.global_position)
return
# Interactables with health (boxes, etc.) - small damage
if "health" in body:
if has_node("SfxImpact"):
$SfxImpact.play()
body.health -= damage
if body.health <= 0 and body.has_method("_break_into_pieces"):
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and multiplayer.has_multiplayer_peer():
var obj_name = body.name
if body.has_meta("object_index"):
var idx = body.get_meta("object_index")
if idx >= 0:
obj_name = "InteractableObject_%d" % idx
if multiplayer.is_server():
game_world._rpc_to_ready_peers("_sync_object_break", [obj_name])
else:
game_world._sync_object_break.rpc_id(1, obj_name)
body._break_into_pieces()
_spawn_damage_effect(body.global_position)
func _spawn_damage_effect(at_position: Vector2) -> void:
if multiplayer.has_multiplayer_peer() and player_owner and player_owner.is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_punch_hit_effect"):
if multiplayer.is_server():
game_world._sync_punch_hit_effect(at_position.x, at_position.y)
if game_world.has_method("_rpc_to_ready_peers"):
game_world._rpc_to_ready_peers("_sync_punch_hit_effect", [at_position.x, at_position.y])
else:
game_world._request_punch_hit_effect.rpc_id(1, at_position.x, at_position.y)
return
if not damage_effect_punch_scene:
return
var effect = damage_effect_punch_scene.instantiate()
var parent = get_parent()
if parent:
parent.add_child(effect)
else:
get_tree().current_scene.add_child(effect)
effect.global_position = at_position
if effect.has_method("setup"):
effect.setup(at_position)

View File

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

View File

@@ -9,6 +9,7 @@ extends Node2D
var player_owner: Node = null var player_owner: Node = null
var hit_targets = {} # Track what we've already hit var hit_targets = {} # Track what we've already hit
var first_hit_targets = {} # Track targets that haven't taken initial damage yet var first_hit_targets = {} # Track targets that haven't taken initial damage yet
var bodies_in_area = {} # Track bodies currently in the area (for enter/exit detection)
var damage_timer: float = 0.0 var damage_timer: float = 0.0
var animation_timer: float = 0.0 var animation_timer: float = 0.0
var current_frame: int = 4 # Start at frame 4 (first burning frame) var current_frame: int = 4 # Start at frame 4 (first burning frame)
@@ -133,6 +134,7 @@ func _start_sprite_animation():
func _deal_periodic_damage(): func _deal_periodic_damage():
# Get all bodies in the area # Get all bodies in the area
var bodies = hit_area.get_overlapping_bodies() var bodies = hit_area.get_overlapping_bodies()
var current_bodies = {}
for body in bodies: for body in bodies:
if body == player_owner: if body == player_owner:
@@ -142,16 +144,22 @@ func _deal_periodic_damage():
if player_owner and not player_owner.is_multiplayer_authority(): if player_owner and not player_owner.is_multiplayer_authority():
continue continue
# Check if this is the first hit on this target (for initial damage bonus) current_bodies[body] = true
# Check if this body just entered the area (not in bodies_in_area)
var just_entered = not (body in bodies_in_area)
# If body just entered, deal high damage (like initial hit)
# Also deal high damage on first hit ever
var is_first_hit = not (body in first_hit_targets) var is_first_hit = not (body in first_hit_targets)
if is_first_hit: if is_first_hit:
first_hit_targets[body] = true first_hit_targets[body] = true
# Calculate damage - initial damage is much higher for first hit # Calculate damage - high damage when entering area or first hit, regular damage otherwise
var final_damage = damage var final_damage = damage
var int_bonus_damage = 0.0 # Declare outside if block for use in print statements var int_bonus_damage = 0.0 # Declare outside if block for use in print statements
if is_first_hit: if just_entered or is_first_hit:
# Initial damage is multiplied and gets INT bonus # High damage when entering area (multiplied and gets INT bonus)
final_damage = damage * initial_damage_multiplier final_damage = damage * initial_damage_multiplier
if player_owner and player_owner.character_stats: if player_owner and player_owner.character_stats:
var int_stat = player_owner.character_stats.baseStats.int + player_owner.character_stats.get_pass("int") var int_stat = player_owner.character_stats.baseStats.int + player_owner.character_stats.get_pass("int")
@@ -198,9 +206,9 @@ func _deal_periodic_damage():
else: else:
body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, apply_burn) body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, apply_burn)
if is_first_hit: if just_entered or is_first_hit:
var int_bonus = int_bonus_damage if player_owner and player_owner.character_stats else 0.0 var int_bonus = int_bonus_damage if player_owner and player_owner.character_stats else 0.0
print("Flame spell INITIAL hit enemy: ", body.name, " for ", final_damage, " damage (base: ", damage, " x ", initial_damage_multiplier, " + INT bonus: ", int_bonus, ")") print("Flame spell HIGH DAMAGE hit enemy: ", body.name, " for ", final_damage, " damage (base: ", damage, " x ", initial_damage_multiplier, " + INT bonus: ", int_bonus, ")")
else: else:
print("Flame spell hit enemy: ", body.name, " for ", final_damage, " damage!") print("Flame spell hit enemy: ", body.name, " for ", final_damage, " damage!")
@@ -210,12 +218,19 @@ func _deal_periodic_damage():
continue # Don't break objects while held continue # Don't break objects while held
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
body.take_fire_damage(final_damage, attacker_pos) body.take_fire_damage(final_damage, attacker_pos)
if is_first_hit: if just_entered or is_first_hit:
print("Flame spell HIGH DAMAGE burning wooden object: ", body.name, " for ", final_damage, " damage!")
else:
print("Flame spell burning wooden object: ", body.name, " for ", final_damage, " damage!") print("Flame spell burning wooden object: ", body.name, " for ", final_damage, " damage!")
func _on_body_entered(_body): # Update bodies_in_area for next check
# Track bodies that enter the area (for periodic damage) bodies_in_area = current_bodies
# Don't add to hit_targets here - we want to deal damage multiple times
func _on_body_entered(body):
# Track bodies that enter the area - they will get high damage on next periodic check
# The _deal_periodic_damage function will detect they just entered and deal high damage
if body and body != player_owner:
# Mark that this body just entered (will be processed in _deal_periodic_damage)
pass pass
func _on_lifetime_expired(): func _on_lifetime_expired():

View File

@@ -43,6 +43,9 @@ func _spawn_adjacent_after_delay() -> void:
await get_tree().create_timer(0.5).timeout await get_tree().create_timer(0.5).timeout
if not is_instance_valid(self): if not is_instance_valid(self):
return return
# Round 2: play spike SFX when the 4 adjacent spikes appear
if has_node("SfxSpike"):
$SfxSpike.play()
var gw = get_tree().get_first_node_in_group("game_world") var gw = get_tree().get_first_node_in_group("game_world")
if not gw or not gw.has_method("_get_adjacent_valid_spell_tile_centers"): if not gw or not gw.has_method("_get_adjacent_valid_spell_tile_centers"):
_finish_center_spike() _finish_center_spike()
@@ -62,12 +65,11 @@ func _spawn_adjacent_after_delay() -> void:
await get_tree().create_timer(0.25).timeout await get_tree().create_timer(0.25).timeout
if not is_instance_valid(self): if not is_instance_valid(self):
return return
# Third wave: center again, 2x scale, 2x damage (most damage) — only after 4 finish # Third wave: center again, 2x scale, 2x damage (most damage). Use skip_sfx=false so the
# big spike plays SfxSpike in its _ready() — we can't play on self here because we're about to queue_free().
var third = scene.instantiate() var third = scene.instantiate()
third.setup(global_position, player_owner, damage, false, 2.0, 2.0, true) third.setup(global_position, player_owner, damage, false, 2.0, 2.0, false)
par.add_child(third) par.add_child(third)
if has_node("SfxSpike"):
$SfxSpike.play()
_finish_center_spike() _finish_center_spike()
func _finish_center_spike() -> void: func _finish_center_spike() -> void:

View File

@@ -287,6 +287,10 @@ func modify_health(amount: float, allow_overheal: bool = false) -> void:
hp = max(0.0, hp) hp = max(0.0, hp)
else: else:
hp = clamp(hp, 0.0, maxhp) hp = clamp(hp, 0.0, maxhp)
# Fix floating point precision: if HP is very close to 0 (within epsilon), set it to exactly 0.0
# This ensures death checks work correctly when HP reaches exactly 0
if hp <= 0.001:
hp = 0.0
health_changed.emit(hp, maxhp) health_changed.emit(hp, maxhp)
character_changed.emit(self) character_changed.emit(self)
@@ -321,7 +325,9 @@ func take_damage(amount: float, is_magical: bool = false) -> float:
# Calculate damage after DEF reduction # Calculate damage after DEF reduction
var actual_damage = calculate_damage(amount, is_magical) var actual_damage = calculate_damage(amount, is_magical)
modify_health(-actual_damage) modify_health(-actual_damage)
if hp <= 0: # Check if dead (use epsilon to handle floating point precision)
if hp <= 0.001:
hp = 0.0 # Ensure exactly 0
no_health.emit() # Emit when health reaches 0 no_health.emit() # Emit when health reaches 0
character_changed.emit(self) character_changed.emit(self)
return actual_damage return actual_damage

View File

@@ -0,0 +1,27 @@
extends Node2D
# Arrow hit effect when arrow damages enemy - frames 335-339 from shade_spell_effects.png
const DURATION: float = 0.2
const FRAMES: Array = [335, 336, 337, 338, 339]
const FRAME_DURATION: float = 0.04
var elapsed: float = 0.0
@onready var fx_sprite: Sprite2D = $FxSprite
func _ready() -> void:
if fx_sprite and FRAMES.size() > 0:
fx_sprite.frame = FRAMES[0]
func setup(_position: Vector2 = Vector2.ZERO) -> void:
if _position != Vector2.ZERO:
global_position = _position
func _process(delta: float) -> void:
elapsed += delta
if fx_sprite and FRAMES.size() > 0:
var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1)
fx_sprite.frame = FRAMES[idx]
if elapsed >= DURATION:
queue_free()

View File

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

View File

@@ -0,0 +1,27 @@
extends Node2D
# Axe hit effect when axe swing damages target - frames 1158-1162 from shade_spell_effects.png
const DURATION: float = 0.2
const FRAMES: Array = [1158, 1159, 1160, 1161, 1162]
const FRAME_DURATION: float = 0.04
var elapsed: float = 0.0
@onready var fx_sprite: Sprite2D = $FxSprite
func _ready() -> void:
if fx_sprite and FRAMES.size() > 0:
fx_sprite.frame = FRAMES[0]
func setup(_position: Vector2 = Vector2.ZERO) -> void:
if _position != Vector2.ZERO:
global_position = _position
func _process(delta: float) -> void:
elapsed += delta
if fx_sprite and FRAMES.size() > 0:
var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1)
fx_sprite.frame = FRAMES[idx]
if elapsed >= DURATION:
queue_free()

View File

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

View File

@@ -0,0 +1,27 @@
extends Node2D
# Bite hit effect when bat/rat damages player - frames 148-158 from shade_spell_effects.png
const DURATION: float = 0.44
const FRAMES: Array = [148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158]
const FRAME_DURATION: float = 0.04
var elapsed: float = 0.0
@onready var fx_sprite: Sprite2D = $FxSprite
func _ready() -> void:
if fx_sprite and FRAMES.size() > 0:
fx_sprite.frame = FRAMES[0]
func setup(_position: Vector2 = Vector2.ZERO) -> void:
if _position != Vector2.ZERO:
global_position = _position
func _process(delta: float) -> void:
elapsed += delta
if fx_sprite and FRAMES.size() > 0:
var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1)
fx_sprite.frame = FRAMES[idx]
if elapsed >= DURATION:
queue_free()

View File

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

View File

@@ -0,0 +1,27 @@
extends Node2D
# Temporary hit effect for punch - plays frames 451-458 from shade_spell_effects.png
const DURATION: float = 0.32
const FRAMES: Array = [451, 452, 453, 454, 455, 456, 457, 458]
const FRAME_DURATION: float = 0.04
var elapsed: float = 0.0
@onready var fx_sprite: Sprite2D = $FxSprite
func _ready() -> void:
if fx_sprite and FRAMES.size() > 0:
fx_sprite.frame = FRAMES[0]
func setup(_position: Vector2 = Vector2.ZERO) -> void:
if _position != Vector2.ZERO:
global_position = _position
func _process(delta: float) -> void:
elapsed += delta
if fx_sprite and FRAMES.size() > 0:
var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1)
fx_sprite.frame = FRAMES[idx]
if elapsed >= DURATION:
queue_free()

View File

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

View File

@@ -0,0 +1,27 @@
extends Node2D
# Slash hit effect when sword projectile damages enemy - frames 948-952 from shade_spell_effects.png
const DURATION: float = 0.2
const FRAMES: Array = [948, 949, 950, 951, 952]
const FRAME_DURATION: float = 0.04
var elapsed: float = 0.0
@onready var fx_sprite: Sprite2D = $FxSprite
func _ready() -> void:
if fx_sprite and FRAMES.size() > 0:
fx_sprite.frame = FRAMES[0]
func setup(_position: Vector2 = Vector2.ZERO) -> void:
if _position != Vector2.ZERO:
global_position = _position
func _process(delta: float) -> void:
elapsed += delta
if fx_sprite and FRAMES.size() > 0:
var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1)
fx_sprite.frame = FRAMES[idx]
if elapsed >= DURATION:
queue_free()

View File

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

View File

@@ -164,7 +164,10 @@ func _physics_process(delta):
var actual_damage = old_hp - character_stats.hp var actual_damage = old_hp - character_stats.hp
LogManager.log(str(name) + " takes " + str(actual_damage) + " burn damage! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " takes " + str(actual_damage) + " burn damage! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY)
# Show damage number for burn damage # Show damage number for burn damage
_show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number # Use a position slightly offset from global_position to ensure proper direction calculation
# (if from_position equals global_position, direction becomes Vector2.ZERO which causes issues)
var burn_damage_source_pos = global_position + Vector2(randf_range(-10, 10), -20) # Slight random offset above enemy
_show_damage_number(actual_damage, burn_damage_source_pos, false, false, false) # Show burn damage number
# Sync burn damage visual to clients # Sync burn damage visual to clients
if multiplayer.has_multiplayer_peer() and is_inside_tree(): if multiplayer.has_multiplayer_peer() and is_inside_tree():
var enemy_name = name var enemy_name = name
@@ -303,6 +306,14 @@ func _attack_player(player):
player.rpc_take_damage.rpc(damage, global_position) player.rpc_take_damage.rpc(damage, global_position)
attack_timer = attack_cooldown attack_timer = attack_cooldown
LogManager.log(str(name) + " attacked " + str(player.name) + " (peer: " + str(player_peer_id) + ", server: " + str(multiplayer.get_unique_id()) + ")", LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " attacked " + str(player.name) + " (peer: " + str(player_peer_id) + ", server: " + str(multiplayer.get_unique_id()) + ")", LogManager.CATEGORY_ENEMY)
# Bite effect when bat or rat hits player
var script_path = get_script().resource_path if get_script() else ""
if "enemy_bat" in script_path or "enemy_rat" in script_path:
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_bite_effect"):
game_world._sync_bite_effect(player.name)
if game_world.has_method("_rpc_to_ready_peers"):
game_world._rpc_to_ready_peers("_sync_bite_effect", [player.name])
func _find_nearest_player() -> Node: func _find_nearest_player() -> Node:
var players = get_tree().get_nodes_in_group("player") var players = get_tree().get_nodes_in_group("player")
@@ -471,7 +482,6 @@ func rpc_heal_enemy(amount: float, allow_overheal: bool = false):
func _show_damage_number(amount: float, from_position: Vector2, is_critical: bool = false, is_miss: bool = false, is_dodged: bool = false): func _show_damage_number(amount: float, from_position: Vector2, is_critical: bool = false, is_miss: bool = false, is_dodged: bool = false):
# Show damage number (red/orange for crits, cyan for dodge, gray for miss, using dmg_numbers.png font) above enemy # Show damage number (red/orange for crits, cyan for dodge, gray for miss, using dmg_numbers.png font) above enemy
# Show even if amount is 0 for MISS/DODGED # Show even if amount is 0 for MISS/DODGED
var damage_number_scene = preload("res://scenes/damage_number.tscn") var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene: if not damage_number_scene:
return return
@@ -512,6 +522,38 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo
get_tree().current_scene.add_child(damage_label) get_tree().current_scene.add_child(damage_label)
damage_label.global_position = global_position + Vector2(0, -16) damage_label.global_position = global_position + Vector2(0, -16)
func _show_exp_number(amount: float, exp_pos: Vector2):
# Show EXP number (green/yellow, using dmg_numbers.png font) at position
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
var exp_label = damage_number_scene.instantiate()
if not exp_label:
return
# Set text and color for EXP (green/yellow)
exp_label.label = "+" + str(int(amount)) + " EXP"
exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
exp_label.z_index = 5
# Direction is straight up
exp_label.direction = Vector2(0, -1)
# Position at the specified location
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
entities_node.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
func _flash_damage(): func _flash_damage():
# Flash red visual effect # Flash red visual effect
if sprite: if sprite:
@@ -661,16 +703,11 @@ func _die():
game_world.defeated_enemies[enemy_index] = true game_world.defeated_enemies[enemy_index] = true
LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY) LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY)
# Credit kill and grant EXP to the player who dealt the fatal damage # Credit kill to the player who dealt the fatal damage
if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: if killer_player and is_instance_valid(killer_player) and killer_player.character_stats:
killer_player.character_stats.kills += 1 killer_player.character_stats.kills += 1
LogManager.log(str(name) + " kill credited to " + str(killer_player.name) + " (total kills: " + str(killer_player.character_stats.kills) + ")", LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " kill credited to " + str(killer_player.name) + " (total kills: " + str(killer_player.character_stats.kills) + ")", LogManager.CATEGORY_ENEMY)
# Grant EXP to the killer
if exp_reward > 0:
killer_player.character_stats.add_xp(exp_reward)
LogManager.log(str(name) + " granted " + str(exp_reward) + " EXP to " + str(killer_player.name), LogManager.CATEGORY_ENEMY)
# Sync kill update to client if this player belongs to a client # Sync kill update to client if this player belongs to a client
# Only sync if we're on the server and the killer is a client's player # Only sync if we're on the server and the killer is a client's player
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
@@ -679,8 +716,41 @@ func _die():
if killer_peer_id != 0 and killer_peer_id != multiplayer.get_unique_id() and killer_player.has_method("_sync_stats_update"): if killer_peer_id != 0 and killer_peer_id != multiplayer.get_unique_id() and killer_player.has_method("_sync_stats_update"):
# Server is updating a client's player stats - sync to the client # Server is updating a client's player stats - sync to the client
var coins = killer_player.character_stats.coin if "coin" in killer_player.character_stats else 0 var coins = killer_player.character_stats.coin if "coin" in killer_player.character_stats else 0
LogManager.log(str(name) + " syncing kill stats to client peer_id=" + str(killer_peer_id) + " kills=" + str(killer_player.character_stats.kills) + " coins=" + str(coins), LogManager.CATEGORY_ENEMY) var xp = killer_player.character_stats.xp if "xp" in killer_player.character_stats else 0.0
killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins) LogManager.log(str(name) + " syncing kill stats to client peer_id=" + str(killer_peer_id) + " kills=" + str(killer_player.character_stats.kills) + " coins=" + str(coins) + " xp=" + str(xp), LogManager.CATEGORY_ENEMY)
killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins, xp)
# Grant EXP to all players (split evenly among all players)
if exp_reward > 0:
var all_players = get_tree().get_nodes_in_group("player")
var valid_players = []
for player in all_players:
if is_instance_valid(player) and player.character_stats:
valid_players.append(player)
if valid_players.size() > 0:
# Split EXP evenly among all players
var exp_per_player = exp_reward / valid_players.size()
for player in valid_players:
player.character_stats.add_xp(exp_per_player)
LogManager.log(str(name) + " granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(exp_reward) + " total)", LogManager.CATEGORY_ENEMY)
# Sync EXP to client if this player belongs to a client
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
var player_peer_id = player.get_multiplayer_authority()
if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"):
var coins = player.character_stats.coin if "coin" in player.character_stats else 0
var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0
player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
# Show floating EXP text at enemy position and sync to all clients
if is_multiplayer_authority():
# Show locally first
_show_exp_number(exp_per_player, global_position)
# Sync to all clients via game_world
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position)
# Spawn loot immediately (before death animation) # Spawn loot immediately (before death animation)
_spawn_loot() _spawn_loot()
@@ -771,10 +841,11 @@ func _spawn_loot():
var lck_bonus_item = min(killer_lck * 0.01, 0.2) # Up to +20% item chance at LCK 20+ var lck_bonus_item = min(killer_lck * 0.01, 0.2) # Up to +20% item chance at LCK 20+
var lck_penalty_coin = min(killer_lck * 0.005, 0.15) # Up to -15% coin chance at LCK 30+ var lck_penalty_coin = min(killer_lck * 0.005, 0.15) # Up to -15% coin chance at LCK 30+
# Base probabilities: 50% coin, 20% food, 30% item # Base probabilities: 70% coin, 15% food, 15% item (reduced from 30%)
var coin_chance = 0.5 - lck_penalty_coin # Items are further split: 80% consumables, 20% equipment (making equipment very rare)
var food_chance = 0.2 var coin_chance = 0.7 - lck_penalty_coin
var item_chance = 0.3 + lck_bonus_item var food_chance = 0.15
var item_chance = 0.15 + lck_bonus_item # Reduced from 0.3
# Normalize probabilities # Normalize probabilities
var total = coin_chance + food_chance + item_chance var total = coin_chance + food_chance + item_chance
@@ -844,38 +915,18 @@ func _spawn_loot():
safe_spawn_pos = game_world._find_nearby_safe_spawn_position(safe_spawn_pos, 32.0) safe_spawn_pos = game_world._find_nearby_safe_spawn_position(safe_spawn_pos, 32.0)
if drop_item: if drop_item:
# Spawn Item instance as loot - LCK influences rarity # Spawn Item instance as loot - prioritize consumables over equipment
# 80% consumables (arrows, bombs, restoration), 20% equipment
var item_type_roll = randf()
var item = null var item = null
if item_rarity_boost:
# High LCK: use chest rarity weights (better loot) instead of enemy drop weights
# Roll for rarity with LCK bonus: each 5 LCK above 15 increases rare/epic chance
var rarity_roll = randf()
var lck_rarity_bonus = min((killer_lck - 15.0) * 0.02, 0.15) # Up to +15% rare/epic chance
# Clamp values to prevent going below 0 or above 1 if item_type_roll < 0.8:
var common_threshold = max(0.0, 0.3 - lck_rarity_bonus) # Consumable drop (arrows, bombs, restoration items)
var uncommon_threshold = max(common_threshold, 0.65 - (lck_rarity_bonus * 0.5)) item = ItemDatabase.get_random_consumable_drop()
var rare_threshold = min(1.0, 0.90 + (lck_rarity_bonus * 2.0))
if rarity_roll < common_threshold:
# Common (reduced by LCK)
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.COMMON)
elif rarity_roll < uncommon_threshold:
# Uncommon (slightly reduced)
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.UNCOMMON)
elif rarity_roll < rare_threshold:
# Rare (increased by LCK)
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.RARE)
else: else:
# Epic/Consumable (greatly increased by LCK) # Equipment drop (much rarer - only 20% of item drops, which is 20% of 15% = 3% total)
var epic_roll = randf() # LCK boost still applies - higher LCK makes equipment drops more likely to be better quality
if epic_roll < 0.5: item = ItemDatabase.get_random_equipment_drop()
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.EPIC)
else:
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.CONSUMABLE)
else:
# Normal LCK: use standard enemy drop weights
item = ItemDatabase.get_random_enemy_drop()
if item: if item:
ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world) ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world)

View File

@@ -31,7 +31,7 @@ func _ready() -> void:
super._ready() super._ready()
max_health = 25.0 max_health = 25.0
current_health = max_health current_health = max_health
move_speed = 28.0 # Reduced from 55.0 - much slower move_speed = 16.8 # 60% of 28.0 - slower chase/random movement
damage = SNATCH_DAMAGE damage = SNATCH_DAMAGE
exp_reward = 8.0 exp_reward = 8.0
collision_layer = 2 collision_layer = 2

View File

@@ -601,7 +601,6 @@ func _get_body_texture_for_type(type: HumanoidType) -> String:
func _load_random_equipment(): func _load_random_equipment():
# Load random equipment (shoes, clothes, gloves, headgear) # Load random equipment (shoes, clothes, gloves, headgear)
# Equipment is optional - chance to have each piece # Equipment is optional - chance to have each piece
# Random shoes (Layer 1 - Shoes) # Random shoes (Layer 1 - Shoes)
if appearance_rng.randf() < 0.8: # 80% chance to have shoes if appearance_rng.randf() < 0.8: # 80% chance to have shoes
_load_random_shoes() _load_random_shoes()
@@ -1632,6 +1631,12 @@ func _throw_held_bomb():
par.add_child(bomb) par.add_child(bomb)
bomb.global_position = global_position + Vector2(0, -20) # From above head bomb.global_position = global_position + Vector2(0, -20) # From above head
bomb.setup(bomb.global_position, self, fallback_throw_force, true) bomb.setup(bomb.global_position, self, fallback_throw_force, true)
# Sync fallback bomb to joiners
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_rpc_to_ready_peers") and multiplayer.has_multiplayer_peer():
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
var bomb_name = "EnemyBomb_" + name + "_" + str(Time.get_ticks_msec())
game_world._rpc_to_ready_peers("_sync_enemy_throw_bomb", [bomb_name, name, enemy_index, bomb.global_position, fallback_throw_force])
return return
if bombs_left <= 0: if bombs_left <= 0:

View File

@@ -0,0 +1,9 @@
extends Node
# Persists selected race across scene change (main menu -> select class -> game world).
# Player reads this in _setup_player_appearance() and randomizes character for that race.
# Set skip_race_select = true (e.g. via --race=elf) to go straight to game without race select UI.
# race_chosen_before_connect: joiner picked race on main menu before Join; skip race select after connect.
var selected_race: String = "Dwarf"
var skip_race_select: bool = false
var race_chosen_before_connect: bool = false

View File

@@ -0,0 +1 @@
uid://6dgu8mbartys

View File

@@ -5,6 +5,7 @@ extends CanvasLayer
@onready var main_menu = $Control/MainMenu @onready var main_menu = $Control/MainMenu
@onready var host_button = $Control/MainMenu/VBoxContainer/HostButton @onready var host_button = $Control/MainMenu/VBoxContainer/HostButton
@onready var join_button = $Control/MainMenu/VBoxContainer/JoinButton @onready var join_button = $Control/MainMenu/VBoxContainer/JoinButton
@onready var select_race_button = $Control/MainMenu/VBoxContainer/SelectRaceButton
@onready var network_mode_option = $Control/MainMenu/VBoxContainer/NetworkModeContainer/NetworkModeOption @onready var network_mode_option = $Control/MainMenu/VBoxContainer/NetworkModeContainer/NetworkModeOption
@onready var network_mode_container = $Control/MainMenu/VBoxContainer/NetworkModeContainer @onready var network_mode_container = $Control/MainMenu/VBoxContainer/NetworkModeContainer
@onready var local_players_spinbox = $Control/MainMenu/VBoxContainer/LocalPlayersContainer/SpinBox @onready var local_players_spinbox = $Control/MainMenu/VBoxContainer/LocalPlayersContainer/SpinBox
@@ -16,6 +17,11 @@ extends CanvasLayer
@onready var network_manager = $"/root/NetworkManager" @onready var network_manager = $"/root/NetworkManager"
var select_class_scene: PackedScene = preload("res://scenes/select_class.tscn")
var select_class_instance: Node = null # Race select overlay (before dungeon)
var _race_select_standalone: bool = false # True when joiner picks race before Join (don't start game on confirm)
var pending_auto_join_after_race_select: bool = false # --join --webrtc with no --race=: show race select first, then auto-join
var connection_error_label: Label = null var connection_error_label: Label = null
var connection_error_shown: bool = false # Prevent spamming error messages var connection_error_shown: bool = false # Prevent spamming error messages
var is_joining_attempt: bool = false var is_joining_attempt: bool = false
@@ -49,6 +55,8 @@ func _ready():
# Connect buttons # Connect buttons
host_button.pressed.connect(_on_host_pressed) host_button.pressed.connect(_on_host_pressed)
join_button.pressed.connect(_on_join_pressed) join_button.pressed.connect(_on_join_pressed)
if select_race_button:
select_race_button.pressed.connect(_on_select_race_pressed)
# Setup network mode dropdown # Setup network mode dropdown
if network_mode_option: if network_mode_option:
@@ -119,6 +127,24 @@ func _check_command_line_args():
join_address = arg.split("=")[1] join_address = arg.split("=")[1]
elif arg.begins_with("--players="): elif arg.begins_with("--players="):
local_count = int(arg.split("=")[1]) local_count = int(arg.split("=")[1])
elif arg.begins_with("--race="):
var race_arg = arg.split("=")[1].strip_edges().to_lower()
var gs = get_node_or_null("/root/GameState")
if gs:
if race_arg == "dwarf":
gs.selected_race = "Dwarf"
gs.skip_race_select = true
LogManager.log("GameUI: Race set from argument: Dwarf (skip race select)", LogManager.CATEGORY_UI)
elif race_arg == "elf":
gs.selected_race = "Elf"
gs.skip_race_select = true
LogManager.log("GameUI: Race set from argument: Elf (skip race select)", LogManager.CATEGORY_UI)
elif race_arg == "human":
gs.selected_race = "Human"
gs.skip_race_select = true
LogManager.log("GameUI: Race set from argument: Human (skip race select)", LogManager.CATEGORY_UI)
else:
LogManager.log("GameUI: Ignoring invalid --race=" + race_arg + " (use dwarf, elf, or human)", LogManager.CATEGORY_UI)
LogManager.log("GameUI: Parsed flags - should_host: " + str(should_host) + ", should_join: " + str(should_join) + ", force_webrtc: " + str(force_webrtc) + ", should_debug: " + str(should_debug), LogManager.CATEGORY_UI) LogManager.log("GameUI: Parsed flags - should_host: " + str(should_host) + ", should_join: " + str(should_join) + ", force_webrtc: " + str(force_webrtc) + ", should_debug: " + str(should_debug), LogManager.CATEGORY_UI)
@@ -146,29 +172,35 @@ func _check_command_line_args():
LogManager.log("Auto-hosting due to --host argument", LogManager.CATEGORY_UI) LogManager.log("Auto-hosting due to --host argument", LogManager.CATEGORY_UI)
network_manager.set_local_player_count(local_count) network_manager.set_local_player_count(local_count)
if network_manager.host_game(): if network_manager.host_game():
_start_game() call_deferred("_show_race_select")
elif should_join: elif should_join:
# Check network mode after it's been set # Check network mode after it's been set
var current_mode = network_manager.network_mode var current_mode = network_manager.network_mode
if join_address.is_empty() and (current_mode == 1 or current_mode == 2): if join_address.is_empty() and (current_mode == 1 or current_mode == 2):
# No address provided, and using WebRTC or WebSocket - fetch and auto-join first available room # WebRTC/WebSocket with no address: auto-join first room. If no --race=, show race select first.
LogManager.log("Auto-joining: No address provided, fetching available rooms (mode: " + str(current_mode) + ")...", LogManager.CATEGORY_UI) var gs = get_node_or_null("/root/GameState")
if gs and gs.skip_race_select:
# --race= was passed: skip race select and auto-join immediately
LogManager.log("Auto-joining: No address provided, fetching available rooms (mode: " + str(current_mode) + ", race: " + gs.selected_race + ")...", LogManager.CATEGORY_UI)
network_manager.set_local_player_count(local_count) network_manager.set_local_player_count(local_count)
is_auto_joining = true is_auto_joining = true
is_joining_attempt = true # Mark as joining attempt so connection failure handler works is_joining_attempt = true
# Create timer for retrying room fetches
room_fetch_timer = Timer.new() room_fetch_timer = Timer.new()
room_fetch_timer.name = "RoomFetchTimer" room_fetch_timer.name = "RoomFetchTimer"
room_fetch_timer.wait_time = 2.0 # Retry every 2 seconds room_fetch_timer.wait_time = 2.0
room_fetch_timer.timeout.connect(_retry_room_fetch) room_fetch_timer.timeout.connect(_retry_room_fetch)
room_fetch_timer.autostart = false room_fetch_timer.autostart = false
add_child(room_fetch_timer) add_child(room_fetch_timer)
# Connect to rooms_fetched signal (not one-shot, so we can keep retrying)
if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join):
network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join) network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join)
# Show room fetch status UI and start fetching
_show_room_fetch_status() _show_room_fetch_status()
_start_room_fetch() _start_room_fetch()
else:
# No --race=: show race select first; after they confirm we start auto-join
LogManager.log("Auto-join with WebRTC/WebSocket: choose race first (no --race= passed)", LogManager.CATEGORY_UI)
pending_auto_join_after_race_select = true
_race_select_standalone = true
call_deferred("_show_race_select_ui")
elif not join_address.is_empty(): elif not join_address.is_empty():
LogManager.log("Auto-joining to " + join_address + " due to --join argument", LogManager.CATEGORY_UI) LogManager.log("Auto-joining to " + join_address + " due to --join argument", LogManager.CATEGORY_UI)
address_input.text = join_address address_input.text = join_address
@@ -291,6 +323,26 @@ func _on_rooms_fetched_auto_join(rooms: Array):
_show_loading_indicator() _show_loading_indicator()
_start_room_fetch() _start_room_fetch()
func _start_pending_auto_join():
"""Start auto-join flow after user chose race (--join --webrtc with no --race=)."""
if main_menu:
main_menu.visible = true # Room fetch status lives inside MainMenu
var local_count = int(local_players_spinbox.value) if local_players_spinbox else 1
network_manager.set_local_player_count(local_count)
is_auto_joining = true
is_joining_attempt = true
if not room_fetch_timer:
room_fetch_timer = Timer.new()
room_fetch_timer.name = "RoomFetchTimer"
room_fetch_timer.wait_time = 2.0
room_fetch_timer.timeout.connect(_retry_room_fetch)
room_fetch_timer.autostart = false
add_child(room_fetch_timer)
if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join):
network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join)
_show_room_fetch_status()
_start_room_fetch()
func _retry_room_fetch(): func _retry_room_fetch():
"""Retry fetching available rooms""" """Retry fetching available rooms"""
if not is_auto_joining: if not is_auto_joining:
@@ -632,7 +684,7 @@ func _on_host_pressed():
print("Share this code with players!") print("Share this code with players!")
else: else:
print("Hosting ENet game with ", local_count, " local players") print("Hosting ENet game with ", local_count, " local players")
_start_game() _show_race_select()
func _on_join_pressed(): func _on_join_pressed():
# Reset error state when attempting new connection # Reset error state when attempting new connection
@@ -678,7 +730,16 @@ func _on_connection_succeeded():
if not is_inside_tree(): if not is_inside_tree():
LogManager.log_error("GameUI: Cannot start game - node not in tree", LogManager.CATEGORY_UI) LogManager.log_error("GameUI: Cannot start game - node not in tree", LogManager.CATEGORY_UI)
return return
# Use call_deferred to ensure we're in a safe state to change scenes # Joiner must load game_world immediately so host's spawn RPCs can be processed. Use race from
# GameState (set in _on_race_selected when they picked; do not overwrite). No race select after connect.
var gs = get_node_or_null("/root/GameState")
if gs:
gs.race_chosen_before_connect = false
# Only default to Dwarf if nothing was ever set (e.g. --join --webrtc --race=elf skips UI)
if gs.selected_race.is_empty():
gs.selected_race = "Dwarf"
print("GameUI: Connection succeeded, starting game - GameState.selected_race = '", gs.selected_race if gs else "Dwarf", "' (joiner will use this for their player)")
LogManager.log("GameUI: Connection succeeded, starting game (race: " + (gs.selected_race if gs else "Dwarf") + ")", LogManager.CATEGORY_UI)
call_deferred("_start_game") call_deferred("_start_game")
func _on_connection_failed(): func _on_connection_failed():
@@ -769,15 +830,83 @@ func _hide_connection_error():
connection_error_label.queue_free() connection_error_label.queue_free()
connection_error_label = null connection_error_label = null
func _on_select_race_pressed():
"""Joiner picks race before connecting. Show race select; on confirm we just store choice and return to menu."""
_race_select_standalone = true
_show_race_select_ui()
func _show_race_select():
"""Show race select before dungeon (after Host or before Join). User picks race; on confirm we start game."""
if not is_inside_tree():
return
var gs = get_node_or_null("/root/GameState")
if gs and gs.skip_race_select:
LogManager.log("GameUI: Skipping race select (race from args: " + gs.selected_race + ")", LogManager.CATEGORY_UI)
_start_game()
return
_race_select_standalone = false
_show_race_select_ui()
func _show_race_select_ui():
"""Show race select overlay. _race_select_standalone: true = joiner picking before Join (don't start game)."""
if not is_inside_tree():
return
if select_class_instance and is_instance_valid(select_class_instance):
return
if main_menu:
main_menu.visible = false
var sel = select_class_scene.instantiate()
add_child(sel)
select_class_instance = sel
if sel.has_signal("race_selected"):
sel.race_selected.connect(_on_race_selected)
LogManager.log("GameUI: Race select shown" + (" (choose before Join)" if _race_select_standalone else ""), LogManager.CATEGORY_UI)
func _on_race_selected(race_name: String):
"""User confirmed race. Standalone = joiner chose before Join: store and return to menu. Else start game."""
if select_class_instance and is_instance_valid(select_class_instance):
select_class_instance.queue_free()
select_class_instance = null
# Always persist chosen race to GameState (select_class sets it too; this guarantees it from signal)
var gs = get_node_or_null("/root/GameState")
if gs and not race_name.is_empty():
gs.selected_race = race_name
LogManager.log("GameUI: JOINER PICKED race='" + race_name + "', GameState.selected_race set to '" + gs.selected_race + "'", LogManager.CATEGORY_UI)
if _race_select_standalone:
_race_select_standalone = false
if gs:
gs.race_chosen_before_connect = true
if pending_auto_join_after_race_select:
# --join --webrtc with no --race=: they just chose race, now start auto-join
pending_auto_join_after_race_select = false
_start_pending_auto_join()
LogManager.log("GameUI: Race chosen (" + race_name + "), fetching rooms to auto-join...", LogManager.CATEGORY_UI)
else:
if main_menu:
main_menu.visible = true
LogManager.log("GameUI: Race chosen for joining (" + race_name + "). Press Join when ready.", LogManager.CATEGORY_UI)
return
_start_game()
func _start_game(): func _start_game():
# Check if node is still in the tree before trying to access get_tree() # Check if node is still in the tree before trying to access get_tree()
if not is_inside_tree(): if not is_inside_tree():
LogManager.log_error("GameUI: Cannot change scene - node is not in tree", LogManager.CATEGORY_UI) LogManager.log_error("GameUI: Cannot change scene - node is not in tree", LogManager.CATEGORY_UI)
return return
# Hide menu # Disconnect network callbacks so we don't get signals on freed game_ui after scene change
if network_manager:
if network_manager.connection_succeeded.is_connected(_on_connection_succeeded):
network_manager.connection_succeeded.disconnect(_on_connection_succeeded)
if network_manager.connection_failed.is_connected(_on_connection_failed):
network_manager.connection_failed.disconnect(_on_connection_failed)
# Hide menu (and race select if still present)
if main_menu: if main_menu:
main_menu.visible = false main_menu.visible = false
if select_class_instance and is_instance_valid(select_class_instance):
select_class_instance.queue_free()
select_class_instance = null
# Load the game scene # Load the game scene
var tree = get_tree() var tree = get_tree()

View File

@@ -442,6 +442,12 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
if is_inside_tree(): if is_inside_tree():
_rpc_to_ready_peers("_sync_spawn_player", [peer_id, local_count]) _rpc_to_ready_peers("_sync_spawn_player", [peer_id, local_count])
) )
# Push existing players' state (equipment, inventory, race, appearance) to the new joiner
# so they see the host's Wizard inventory (tomes, etc.). Delay so joiner has spawned player nodes first.
get_tree().create_timer(0.4).timeout.connect(func():
if is_inside_tree():
_push_existing_players_state_to_client(peer_id)
)
# Sync broken interactable objects to the new client # Sync broken interactable objects to the new client
# Wait a bit after dungeon sync to ensure objects are spawned first # Wait a bit after dungeon sync to ensure objects are spawned first
@@ -499,6 +505,21 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
_sync_interactable_object_positions_to_client(peer_id) _sync_interactable_object_positions_to_client(peer_id)
) )
func _push_existing_players_state_to_client(peer_id: int) -> void:
# Server: push each server-authority player's full state (equipment, inventory, race, appearance) to the new joiner.
# Joiner otherwise never receives host's inventory because _sync_inventory was sent before joiner was "ready".
if not multiplayer.is_server():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var server_id = multiplayer.get_unique_id()
for child in entities_node.get_children():
if child.is_in_group("player") and child.get_multiplayer_authority() == server_id:
if child.has_method("_push_full_state_to_peer"):
child._push_full_state_to_peer(peer_id)
LogManager.log("GameWorld: Pushed existing players state to client " + str(peer_id), LogManager.CATEGORY_NETWORK)
func _rpc_to_ready_peers(method: String, args: Array = []): func _rpc_to_ready_peers(method: String, args: Array = []):
# Send RPC to all clients whose GameWorld is ready # Send RPC to all clients whose GameWorld is ready
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
@@ -1388,6 +1409,38 @@ func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, at
# This is okay, just log it # This is okay, just log it
print("GameWorld: Could not find enemy for attack sync: name=", enemy_name, " index=", enemy_index) print("GameWorld: Could not find enemy for attack sync: name=", enemy_name, " index=", enemy_index)
@rpc("authority", "reliable")
func _sync_enemy_throw_bomb(bomb_name: String, enemy_name: String, enemy_index: int, bomb_pos: Vector2, throw_force: Vector2):
# Clients spawn bomb when server tells them an enemy threw one
if multiplayer.is_server():
return # Server ignores this (it's the sender)
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var enemy = null
for child in entities_node.get_children():
if child.is_in_group("enemy"):
if child.name == enemy_name:
enemy = child
break
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
enemy = child
break
if not enemy:
return
var attack_bomb_scene = load("res://scenes/attack_bomb.tscn")
if not attack_bomb_scene:
push_error("GameWorld: Could not load attack_bomb scene for enemy throw sync!")
return
var bomb = attack_bomb_scene.instantiate()
bomb.name = bomb_name
entities_node.add_child(bomb)
bomb.global_position = bomb_pos
bomb.setup(bomb_pos, enemy, throw_force, true) # true = is_thrown
if bomb.has_node("Sprite2D"):
bomb.get_node("Sprite2D").visible = true
print("GameWorld: (client) synced enemy bomb: ", bomb_name, " at ", bomb_pos)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_peer_id: int): func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_peer_id: int):
# Server receives item drop request from client # Server receives item drop request from client
@@ -1468,6 +1521,111 @@ func _request_enemy_damage(enemy_name: String, enemy_index: int, damage: float,
# Enemy not found - might already be freed # Enemy not found - might already be freed
print("GameWorld: Could not find enemy for damage request: name=", enemy_name, " index=", enemy_index) print("GameWorld: Could not find enemy for damage request: name=", enemy_name, " index=", enemy_index)
@rpc("any_peer", "reliable")
func _request_punch_hit_effect(pos_x: float, pos_y: float):
# Server receives request from client (punch hit); broadcast effect to all peers
if not multiplayer.is_server():
return
_sync_punch_hit_effect(pos_x, pos_y)
if has_method("_rpc_to_ready_peers"):
_rpc_to_ready_peers("_sync_punch_hit_effect", [pos_x, pos_y])
@rpc("any_peer", "reliable")
func _sync_punch_hit_effect(pos_x: float, pos_y: float):
# Spawn punch hit effect on this peer (called by server or when server broadcasts)
var scene = load("res://scenes/damage_effect_punch.tscn") as PackedScene
if not scene:
return
var effect = scene.instantiate()
add_child(effect)
effect.global_position = Vector2(pos_x, pos_y)
if effect.has_method("setup"):
effect.setup(Vector2(pos_x, pos_y))
@rpc("any_peer", "reliable")
func _request_slash_hit_effect(pos_x: float, pos_y: float):
if not multiplayer.is_server():
return
_sync_slash_hit_effect(pos_x, pos_y)
if has_method("_rpc_to_ready_peers"):
_rpc_to_ready_peers("_sync_slash_hit_effect", [pos_x, pos_y])
@rpc("any_peer", "reliable")
func _sync_slash_hit_effect(pos_x: float, pos_y: float):
var scene = load("res://scenes/damage_effect_slash.tscn") as PackedScene
if not scene:
return
var effect = scene.instantiate()
add_child(effect)
effect.global_position = Vector2(pos_x, pos_y)
if effect.has_method("setup"):
effect.setup(Vector2(pos_x, pos_y))
@rpc("any_peer", "reliable")
func _request_arrow_hit_effect(pos_x: float, pos_y: float):
if not multiplayer.is_server():
return
_sync_arrow_hit_effect(pos_x, pos_y)
if has_method("_rpc_to_ready_peers"):
_rpc_to_ready_peers("_sync_arrow_hit_effect", [pos_x, pos_y])
@rpc("any_peer", "reliable")
func _sync_arrow_hit_effect(pos_x: float, pos_y: float):
var scene = load("res://scenes/damage_effect_arrow.tscn") as PackedScene
if not scene:
return
var effect = scene.instantiate()
add_child(effect)
effect.global_position = Vector2(pos_x, pos_y)
if effect.has_method("setup"):
effect.setup(Vector2(pos_x, pos_y))
@rpc("any_peer", "reliable")
func _request_axe_hit_effect(pos_x: float, pos_y: float):
if not multiplayer.is_server():
return
_sync_axe_hit_effect(pos_x, pos_y)
if has_method("_rpc_to_ready_peers"):
_rpc_to_ready_peers("_sync_axe_hit_effect", [pos_x, pos_y])
@rpc("any_peer", "reliable")
func _sync_axe_hit_effect(pos_x: float, pos_y: float):
var scene = load("res://scenes/damage_effect_axe.tscn") as PackedScene
if not scene:
return
var effect = scene.instantiate()
add_child(effect)
effect.global_position = Vector2(pos_x, pos_y)
if effect.has_method("setup"):
effect.setup(Vector2(pos_x, pos_y))
@rpc("any_peer", "reliable")
func _request_bite_effect(player_name: String):
if not multiplayer.is_server():
return
_sync_bite_effect(player_name)
if has_method("_rpc_to_ready_peers"):
_rpc_to_ready_peers("_sync_bite_effect", [player_name])
@rpc("any_peer", "reliable")
func _sync_bite_effect(player_name: String):
var player = player_manager.get_node_or_null(player_name) if player_manager else null
if not player and is_instance_valid(self):
for p in get_tree().get_nodes_in_group("player"):
if p.name == player_name:
player = p
break
if not player or not is_instance_valid(player):
return
var scene = load("res://scenes/damage_effect_bite.tscn") as PackedScene
if not scene:
return
var effect = scene.instantiate()
player.add_child(effect)
effect.position = Vector2.ZERO
if effect.has_method("setup"):
effect.setup(Vector2.ZERO)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _request_loot_pickup(loot_id: int, loot_position: Vector2, player_peer_id: int): func _request_loot_pickup(loot_id: int, loot_position: Vector2, player_peer_id: int):
# Server receives loot pickup request from client # Server receives loot pickup request from client
@@ -1575,6 +1733,19 @@ func _sync_hide_level_complete():
if level_complete_ui: if level_complete_ui:
level_complete_ui.visible = false level_complete_ui.visible = false
@rpc("authority", "reliable")
func _sync_disable_stairs():
# Clients receive stairs disable sync from server
if multiplayer.is_server():
return # Server ignores this (it's the sender)
# Disable stairs Area2D on clients so no player can activate it
var stairs_area = get_node_or_null("StairsArea")
if stairs_area:
stairs_area.set_deferred("monitoring", false)
stairs_area.set_deferred("monitorable", false)
LogManager.log("GameWorld: Client disabled stairs Area2D", LogManager.CATEGORY_DUNGEON)
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _sync_restore_player_controls(): func _sync_restore_player_controls():
# Clients receive restore controls/collision sync from server # Clients receive restore controls/collision sync from server
@@ -2860,6 +3031,20 @@ func _generate_dungeon():
dead_players.clear() dead_players.clear()
respawn_all_check_running = false respawn_all_check_running = false
# CRITICAL: Reset all players' death state flags when transitioning to a new level
# This prevents GAME OVER from showing incorrectly when players are alive in the new level
var players = get_tree().get_nodes_in_group("player")
for player in players:
if is_instance_valid(player):
if "is_dead" in player:
player.is_dead = false
if "is_processing_death" in player:
player.is_processing_death = false
# Reset exit notification flag for new level
if "has_seen_exit_this_level" in player:
player.has_seen_exit_this_level = false
LogManager.log("GameWorld: Reset death state for player " + str(player.name) + " on level transition", LogManager.CATEGORY_DUNGEON)
# Hide game over UI if it exists # Hide game over UI if it exists
var game_over_ui = get_node_or_null("GameOverUI") var game_over_ui = get_node_or_null("GameOverUI")
if game_over_ui: if game_over_ui:
@@ -3076,15 +3261,15 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
Color(0.22, 0.34, 0.30), Color(0.22, 0.34, 0.30),
] ]
6: # 7Neon Cyber (sci-fi / UI / hacking) 6: # 7Ancient Stone (medieval / castles / ruins)
walls = [ walls = [
Color(20/255.0, 240/255.0, 220/255.0), Color(240/255.0, 60/255.0, 220/255.0), Color(120/255.0, 120/255.0, 255/255.0), Color(120 / 255.0, 110 / 255.0, 100 / 255.0), Color(160 / 255.0, 150 / 255.0, 140 / 255.0), Color(200 / 255.0, 190 / 255.0, 180 / 255.0),
Color(10/255.0, 20/255.0, 40/255.0), Color(255/255.0, 255/255.0, 255/255.0), Color(80/255.0, 255/255.0, 180/255.0), Color(60 / 255.0, 55 / 255.0, 50 / 255.0), Color(220 / 255.0, 210 / 255.0, 200 / 255.0), Color(90 / 255.0, 85 / 255.0, 75 / 255.0),
] ]
ground_fallout = [ ground_fallout = [
Color(0.45, 0.28, 0.55), Color(0.52, 0.35, 0.62), Color(0.38, 0.22, 0.48), Color(0.35, 0.28, 0.22), Color(0.40, 0.32, 0.26), Color(0.28, 0.22, 0.18),
Color(0.48, 0.32, 0.58), Color(0.42, 0.26, 0.52), Color(0.28, 0.18, 0.38), Color(0.38, 0.30, 0.24), Color(0.32, 0.26, 0.20), Color(0.24, 0.20, 0.16),
Color(0.22, 0.14, 0.32), Color(0.20, 0.16, 0.14),
] ]
7: # 8⃣ Infernal Lava (hell / bosses / damage) 7: # 8⃣ Infernal Lava (hell / bosses / damage)
walls = [ walls = [
@@ -4356,6 +4541,22 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec
# Clear any ongoing syncs # Clear any ongoing syncs
dungeon_sync_in_progress.clear() dungeon_sync_in_progress.clear()
# CRITICAL: Reset all players' death state flags when transitioning to a new level
# This prevents GAME OVER from showing incorrectly when players are alive in the new level
var players = get_tree().get_nodes_in_group("player")
for player in players:
if is_instance_valid(player):
if "is_dead" in player:
player.is_dead = false
if "is_processing_death" in player:
player.is_processing_death = false
# Reset exit notification flag for new level
if "has_seen_exit_this_level" in player:
player.has_seen_exit_this_level = false
# Also reset game over flag on client
game_over_triggered = false
LogManager.log("GameWorld: Client - Reset death state for all players on level transition", LogManager.CATEGORY_DUNGEON)
# CRITICAL: Store defeated enemies, broken objects, opened chests, and door states in temporary variables # CRITICAL: Store defeated enemies, broken objects, opened chests, and door states in temporary variables
# because _clear_level() will clear them, but we need them for the new level # because _clear_level() will clear them, but we need them for the new level
var temp_defeated_enemies = defeated_enemies.duplicate() var temp_defeated_enemies = defeated_enemies.duplicate()
@@ -5631,6 +5832,39 @@ func _apply_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed
if "activation_area" in trap and trap.activation_area: if "activation_area" in trap and trap.activation_area:
trap.activation_area.monitoring = false trap.activation_area.monitoring = false
# Grant EXP to all players when trap is disarmed (only on server)
if multiplayer.is_server():
var trap_exp_reward = 8.0 # EXP reward for disarming a trap
var all_players = get_tree().get_nodes_in_group("player")
var valid_players = []
for player in all_players:
if is_instance_valid(player) and player.character_stats:
valid_players.append(player)
if valid_players.size() > 0:
# Split EXP evenly among all players
var exp_per_player = trap_exp_reward / valid_players.size()
for player in valid_players:
player.character_stats.add_xp(exp_per_player)
LogManager.log("Trap disarmed: granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(trap_exp_reward) + " total)", LogManager.CATEGORY_DUNGEON)
# Sync EXP to client if this player belongs to a client
var player_peer_id = player.get_multiplayer_authority()
if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"):
var coins = player.character_stats.coin if "coin" in player.character_stats else 0
var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0
player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
# Show floating EXP text at trap position and sync to all clients
if is_instance_valid(trap) and "global_position" in trap:
# Show locally first
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_show_exp_number_at_position"):
game_world._show_exp_number_at_position(exp_per_player, trap.global_position)
# Sync to all clients via game_world
if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
game_world._sync_exp_text_at_position.rpc(exp_per_player, trap.global_position)
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool): func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool):
# Client receives trap state sync by name (avoids node path RPC errors) # Client receives trap state sync by name (avoids node path RPC errors)
@@ -6480,11 +6714,34 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int):
if player.peer_id < new_peer_id: if player.peer_id < new_peer_id:
used_spawn_index += 1 used_spawn_index += 1
# Check if entrance exists (level > 1) - joiners should use entrance if available
var has_entrance = not dungeon_data.is_empty() and dungeon_data.has("entrance") and not dungeon_data.entrance.is_empty()
if has_entrance and current_level > 1:
# Position new joiner in entrance and start walk-out sequence (same as level transition)
# CRITICAL: Disable falling cutscene for joiners in entrance (they should walk out, not fall)
for player in new_joiner_players:
if is_instance_valid(player):
player.spawn_landing = false
player.position_z = 0.0
player.velocity_z = 0.0
player.is_airborne = false
_position_players_in_entrance(new_joiner_players)
else:
# Normal spawn positioning (level 1 or no entrance)
# Assign spawn points to new joiner's players (starting from the next available spawn point) # Assign spawn points to new joiner's players (starting from the next available spawn point)
var spawn_index = used_spawn_index var spawn_index = used_spawn_index
for player in new_joiner_players: for player in new_joiner_players:
if spawn_index < player_manager.spawn_points.size(): if spawn_index < player_manager.spawn_points.size():
var new_pos = player_manager.spawn_points[spawn_index] var new_pos = player_manager.spawn_points[spawn_index]
# CRITICAL: Verify spawn position is safe (on floor, not in wall)
if not _is_safe_spawn_position(new_pos):
# Spawn position is not safe, find a nearby safe position
var safe_pos = _find_nearby_safe_spawn_position(new_pos, 128.0)
LogManager.log("GameWorld: WARNING - Spawn position " + str(new_pos) + " for new joiner " + player.name + " was unsafe, using safe position: " + str(safe_pos), LogManager.CATEGORY_GAMEPLAY)
new_pos = safe_pos
player.global_position = new_pos player.global_position = new_pos
LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at spawn index " + str(spawn_index) + " position " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at spawn index " + str(spawn_index) + " position " + str(new_pos), LogManager.CATEGORY_GAMEPLAY)
spawn_index += 1 spawn_index += 1
@@ -6493,11 +6750,25 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int):
var room_center_x = (start_room.x + start_room.w / 2.0) * 16 var room_center_x = (start_room.x + start_room.w / 2.0) * 16
var room_center_y = (start_room.y + start_room.h / 2.0) * 16 var room_center_y = (start_room.y + start_room.h / 2.0) * 16
var fallback_pos = Vector2(room_center_x, room_center_y) var fallback_pos = Vector2(room_center_x, room_center_y)
# CRITICAL: Verify fallback position is safe
if not _is_safe_spawn_position(fallback_pos):
fallback_pos = _find_nearby_safe_spawn_position(fallback_pos, 128.0)
LogManager.log("GameWorld: WARNING - Fallback spawn position for new joiner " + player.name + " was unsafe, using safe position: " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY)
player.global_position = fallback_pos player.global_position = fallback_pos
LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at start room center " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at start room center " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY)
# Host never receives _sync_player_position_by_name; ensure joiner is visible on host # Host never receives _sync_player_position_by_name; ensure joiner is visible on host
player.visible = true player.visible = true
# CRITICAL: Remove black fade overlay for new joiner (in case it was left from previous level)
# This prevents joiners from seeing a black screen
# Remove on server (for host's view) and sync to joiner's client
_remove_black_fade_overlay()
if multiplayer.has_multiplayer_peer():
_sync_remove_black_fade.rpc_id(new_peer_id)
# Send ALL current player positions to the new joiner (so they see everyone correctly) # Send ALL current player positions to the new joiner (so they see everyone correctly)
# Wait longer to ensure the client has fully loaded the game scene and all player nodes are spawned # Wait longer to ensure the client has fully loaded the game scene and all player nodes are spawned
await get_tree().create_timer(0.3).timeout # Wait 0.3 seconds after positioning await get_tree().create_timer(0.3).timeout # Wait 0.3 seconds after positioning
@@ -7111,6 +7382,17 @@ func _on_player_reached_stairs(player: Node):
# Mark as triggered to prevent re-triggering # Mark as triggered to prevent re-triggering
level_complete_triggered = true level_complete_triggered = true
# CRITICAL: Disable stairs so no other player can activate it
# Use set_deferred() because we're in a signal callback (body_entered)
var stairs_area = get_node_or_null("StairsArea")
if stairs_area:
stairs_area.set_deferred("monitoring", false)
stairs_area.set_deferred("monitorable", false)
LogManager.log("GameWorld: Disabled stairs Area2D to prevent other players from activating", LogManager.CATEGORY_DUNGEON)
# Sync stairs disable to all clients
if multiplayer.has_multiplayer_peer():
_rpc_to_ready_peers("_sync_disable_stairs", [])
# Disable controls and collision for the player who reached stairs # Disable controls and collision for the player who reached stairs
var player_peer_id = player.get_multiplayer_authority() if player.has_method("get_multiplayer_authority") else 0 var player_peer_id = player.get_multiplayer_authority() if player.has_method("get_multiplayer_authority") else 0
player.controls_disabled = true player.controls_disabled = true
@@ -7149,6 +7431,38 @@ func _on_player_reached_stairs(player: Node):
# Stop background music when level completes # Stop background music when level completes
_stop_bg_music() _stop_bg_music()
# Grant EXP to all players for completing the level
var level_completion_exp = 15.0 # EXP reward for completing a level
var all_players = get_tree().get_nodes_in_group("player")
var valid_players = []
for p in all_players:
if is_instance_valid(p) and p.character_stats:
valid_players.append(p)
if valid_players.size() > 0:
# Split EXP evenly among all players
var exp_per_player = level_completion_exp / valid_players.size()
for p in valid_players:
p.character_stats.add_xp(exp_per_player)
LogManager.log("Level complete: granted " + str(exp_per_player) + " EXP to " + str(p.name) + " (shared from " + str(level_completion_exp) + " total)", LogManager.CATEGORY_DUNGEON)
# Sync EXP to client if this player belongs to a client
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var p_peer_id = p.get_multiplayer_authority()
if p_peer_id != 0 and p_peer_id != multiplayer.get_unique_id() and p.has_method("_sync_stats_update"):
var coins = p.character_stats.coin if "coin" in p.character_stats else 0
var xp = p.character_stats.xp if "xp" in p.character_stats else 0.0
p._sync_stats_update.rpc_id(p_peer_id, p.character_stats.kills, coins, xp)
# Show floating EXP text at player position and sync to all clients
if multiplayer.is_server() and is_instance_valid(p):
# Show locally first
_show_exp_number_at_player(exp_per_player, p)
# Sync to all clients via RPC
if multiplayer.has_multiplayer_peer():
var p_peer_id = p.get_multiplayer_authority()
_sync_exp_text_at_player.rpc(exp_per_player, p_peer_id)
# Show level complete UI (server and clients) with per-player stats # Show level complete UI (server and clients) with per-player stats
_show_level_complete_ui(level_time) _show_level_complete_ui(level_time)
# Sync to all clients (each client will show their own local player's stats) # Sync to all clients (each client will show their own local player's stats)
@@ -7598,6 +7912,78 @@ func _load_floating_text_layer() -> void:
layer.follow_viewport_enabled = true layer.follow_viewport_enabled = true
add_child(layer) add_child(layer)
@rpc("any_peer", "call_local", "reliable")
func _sync_exp_text_at_position(amount: float, exp_pos: Vector2):
# Sync EXP text display to all clients at a specific position
_show_exp_number_at_position(amount, exp_pos)
@rpc("any_peer", "call_local", "reliable")
func _sync_exp_text_at_player(amount: float, player_peer_id: int):
# Sync EXP text display to all clients at a player's position
var player = null
var players = get_tree().get_nodes_in_group("player")
for p in players:
if p.get_multiplayer_authority() == player_peer_id:
player = p
break
if player and is_instance_valid(player):
_show_exp_number_at_player(amount, player)
func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
# Show EXP number (green, using dmg_numbers.png font) at a specific position
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
var exp_label = damage_number_scene.instantiate()
if not exp_label:
return
# Set text and color for EXP (green)
exp_label.label = "+" + str(int(amount)) + " EXP"
exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
exp_label.z_index = 5
# Direction is straight up
exp_label.direction = Vector2(0, -1)
# Position at specified location
var entities_node = get_node_or_null("Entities")
if entities_node:
entities_node.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
func _show_exp_number_at_player(amount: float, player: Node):
# Show EXP number (green, using dmg_numbers.png font) at player position
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene or not is_instance_valid(player):
return
var exp_label = damage_number_scene.instantiate()
if not exp_label:
return
# Set text and color for EXP (green)
exp_label.label = "+" + str(int(amount)) + " EXP"
exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
exp_label.z_index = 5
# Direction is straight up
exp_label.direction = Vector2(0, -1)
# Position at player location
var entities_node = get_node_or_null("Entities")
if entities_node:
entities_node.add_child(exp_label)
exp_label.global_position = player.global_position + Vector2(0, -20) # Above player
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = player.global_position + Vector2(0, -20)
func world_to_screen(world_pos: Vector2) -> Vector2: func world_to_screen(world_pos: Vector2) -> Vector2:
var vp = get_viewport() var vp = get_viewport()
if not vp: if not vp:
@@ -7844,6 +8230,46 @@ func _request_register_player_died(player_peer_id: int):
_run_respawn_all_check() _run_respawn_all_check()
return return
func _unregister_player_died(player: Node):
# Remove player from dead_players dictionary when they're revived
# Authority runs this; if client, we RPC server so server has full picture.
if not is_inside_tree():
return
var peer_id: int = 0
if player and player.has_method("get_multiplayer_authority"):
peer_id = player.get_multiplayer_authority()
var n = str(player.name) if player else ""
if n.is_empty():
return
if multiplayer.is_server() and multiplayer.has_multiplayer_peer():
if dead_players.has(n):
dead_players.erase(n)
LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON)
elif multiplayer.has_multiplayer_peer():
_request_unregister_player_died.rpc_id(1, peer_id)
else:
# Single-player: we are "server"
if dead_players.has(n):
dead_players.erase(n)
LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON)
@rpc("any_peer", "reliable")
func _request_unregister_player_died(player_peer_id: int):
if not multiplayer.is_server():
return
var pm = get_node_or_null("PlayerManager")
if not pm or not pm.has_method("get_all_players"):
return
for p in pm.get_all_players():
if not is_instance_valid(p):
continue
if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id:
var n = str(p.name)
if not n.is_empty() and dead_players.has(n):
dead_players.erase(n)
LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON)
return
func _are_all_players_dead_server() -> bool: func _are_all_players_dead_server() -> bool:
var pm = get_node_or_null("PlayerManager") var pm = get_node_or_null("PlayerManager")
if not pm or not pm.has_method("get_all_players"): if not pm or not pm.has_method("get_all_players"):
@@ -7851,13 +8277,49 @@ func _are_all_players_dead_server() -> bool:
var all_p = pm.get_all_players() var all_p = pm.get_all_players()
if all_p.is_empty(): if all_p.is_empty():
return false return false
# Get list of connected peer IDs (exclude disconnected players)
var connected_peer_ids: Array[int] = []
if multiplayer.has_multiplayer_peer():
# Server's own peer ID (1) is always connected
connected_peer_ids.append(1)
# Add all other connected peers
for peer_id in multiplayer.get_peers():
connected_peer_ids.append(peer_id)
else:
# Single-player: only check local players
connected_peer_ids.append(1)
# Only check players that are connected (not disconnected)
var has_connected_players = false
for p in all_p: for p in all_p:
if not is_instance_valid(p): if not is_instance_valid(p):
return false continue
# Get player's peer ID
var player_peer_id = 0
if p.has_method("get_multiplayer_authority"):
player_peer_id = p.get_multiplayer_authority()
elif "peer_id" in p:
player_peer_id = p.peer_id
# Skip disconnected players (not in connected_peer_ids)
if player_peer_id > 0 and player_peer_id not in connected_peer_ids:
continue
# This is a connected player - check if they're dead
has_connected_players = true
var in_dead = dead_players.has(str(p.name)) var in_dead = dead_players.has(str(p.name))
var node_dead = "is_dead" in p and p.is_dead var node_dead = "is_dead" in p and p.is_dead
if not in_dead and not node_dead: if not in_dead and not node_dead:
# Found a connected player who is not dead
return false return false
# If no connected players found, return false (shouldn't happen, but safety check)
if not has_connected_players:
return false
# All connected players are dead
return true return true
func _run_respawn_all_check(): func _run_respawn_all_check():
@@ -7870,6 +8332,14 @@ func _run_respawn_all_check():
respawn_all_check_running = false respawn_all_check_running = false
if not is_inside_tree(): if not is_inside_tree():
return return
# CRITICAL: Double-check that all players are still dead before showing game over
# This prevents showing game over if level transition happened during the wait
if not _are_all_players_dead_server():
# Players are no longer dead (likely level transition happened), abort
dead_players.clear()
return
if game_over_triggered: if game_over_triggered:
pass # Already shown (e.g. by earlier logic) pass # Already shown (e.g. by earlier logic)
else: else:
@@ -7880,6 +8350,18 @@ func _run_respawn_all_check():
await get_tree().create_timer(0.5).timeout await get_tree().create_timer(0.5).timeout
if not is_inside_tree(): if not is_inside_tree():
return return
# CRITICAL: Check again after the wait - level transition might have happened
if not _are_all_players_dead_server():
# Players are no longer dead (level transition happened), abort and reset
game_over_triggered = false
dead_players.clear()
# Hide game over UI if it was shown
var game_over_ui = get_node_or_null("GameOverUI")
if game_over_ui:
game_over_ui.visible = false
return
dead_players.clear() dead_players.clear()
respawn_all_ready.emit() respawn_all_ready.emit()
if multiplayer.has_multiplayer_peer() and is_inside_tree(): if multiplayer.has_multiplayer_peer() and is_inside_tree():
@@ -8051,6 +8533,120 @@ func _sync_arrow_collected(arrow_name: String):
if arrow and is_instance_valid(arrow): if arrow and is_instance_valid(arrow):
arrow.call_deferred("queue_free") arrow.call_deferred("queue_free")
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_hit(arrow_name: String, target_name: String):
# Route arrow hit sync through game_world to avoid node path issues
# Find arrow by name (may be in Entities or as child of target)
if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty():
return
var arrow = _find_arrow_by_name(arrow_name)
if not arrow or not is_instance_valid(arrow):
LogManager.log("GameWorld: Arrow not found for hit sync: " + arrow_name, LogManager.CATEGORY_DUNGEON)
return
# Find target by name in Entities node
var target = null
var entities_node = get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
# Also check if target is a child of Entities (arrow might be stuck to it)
if not target:
for child in entities_node.get_children():
if child.name == target_name:
target = child
break
if not target:
LogManager.log("GameWorld: Arrow hit target not found: " + target_name, LogManager.CATEGORY_DUNGEON)
return
# Call the arrow's internal sync method
if arrow.has_method("_process_arrow_hit_sync"):
arrow._process_arrow_hit_sync(target)
else:
# Fallback: directly stick to target
if arrow.has_method("_stick_to_target") and target not in arrow.hit_targets:
arrow.hit_targets[target] = true
if arrow.has_method("play_impact"):
arrow.play_impact()
arrow._stick_to_target(target)
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_miss(arrow_name: String, target_name: String):
# Route arrow miss sync through game_world to avoid node path issues
if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty():
return
var arrow = _find_arrow_by_name(arrow_name)
if not arrow or not is_instance_valid(arrow):
return
# Find target by name in Entities node
var target = null
var entities_node = get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
if target and target not in arrow.hit_targets:
arrow.hit_targets[target] = true
LogManager.log("GameWorld: Arrow synced as MISS - continuing through: " + (target.name if target else "unknown"), LogManager.CATEGORY_DUNGEON)
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_dodge(arrow_name: String, target_name: String):
# Route arrow dodge sync through game_world to avoid node path issues
if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty():
return
var arrow = _find_arrow_by_name(arrow_name)
if not arrow or not is_instance_valid(arrow):
return
# Find target by name in Entities node
var target = null
var entities_node = get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
if target and target not in arrow.hit_targets:
arrow.hit_targets[target] = true
LogManager.log("GameWorld: Arrow synced as DODGE - continuing through: " + (target.name if target else "unknown"), LogManager.CATEGORY_DUNGEON)
func _find_arrow_by_name(arrow_name: String) -> Node:
# Find arrow by name - check Entities first, then check children of entities (arrows stuck to enemies)
var entities_node = get_node_or_null("Entities")
if not entities_node:
return null
# First check direct children of Entities
var arrow = entities_node.get_node_or_null(arrow_name)
if arrow and is_instance_valid(arrow):
return arrow
# Recursively search children of Entities' children (arrows stuck to enemies/players)
# Arrows can be nested deeper when stuck to targets
for child in entities_node.get_children():
var found = _find_node_recursive(child, arrow_name)
if found:
return found
return null
func _find_node_recursive(node: Node, target_name: String) -> Node:
# Recursively search for a node by name
if not is_instance_valid(node):
return null
if node.name == target_name:
return node
for child in node.get_children():
var found = _find_node_recursive(child, target_name)
if found:
return found
return null
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

@@ -233,12 +233,12 @@ func _handle_air_collision():
_break_into_pieces() _break_into_pieces()
# Damage and knockback player using RPC (pots deal less damage than boxes) # Damage and knockback player using RPC (any thrown object deals max 1 damage to other players)
# Player's take_damage() already handles defense calculation # 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 base_damage = 1.0 # Max 1 damage to other players for any thrown object
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
@@ -568,9 +568,18 @@ 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 (pass our name so they can free the lifted bomb) # Sync bomb throw to other clients
if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority() and by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority():
if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree():
# Player threw: sync via player RPC (pass our name so they can free the lifted bomb)
by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force]) by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force])
elif by_player.is_in_group("enemy"):
# Enemy threw: sync via game_world so joiners see the bomb
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_rpc_to_ready_peers"):
var enemy_index = by_player.get_meta("enemy_index") if by_player.has_meta("enemy_index") else -1
var bomb_name = "EnemyBomb_" + by_player.name + "_" + str(Time.get_ticks_msec())
game_world._rpc_to_ready_peers("_sync_enemy_throw_bomb", [bomb_name, by_player.name, enemy_index, current_pos, force])
# Remove the interactable object # Remove the interactable object
queue_free() queue_free()

View File

@@ -207,10 +207,18 @@ func _find_local_player():
if local_players.size() > 0: if local_players.size() > 0:
local_player = local_players[0] local_player = local_players[0]
if local_player and local_player.character_stats: if local_player and local_player.character_stats:
# Connect to character_changed signal # Connect to character_changed signal (for inventory/equipment changes)
if local_player.character_stats.character_changed.is_connected(_on_character_changed): if local_player.character_stats.character_changed.is_connected(_on_character_changed):
local_player.character_stats.character_changed.disconnect(_on_character_changed) local_player.character_stats.character_changed.disconnect(_on_character_changed)
local_player.character_stats.character_changed.connect(_on_character_changed) local_player.character_stats.character_changed.connect(_on_character_changed)
# Connect to mana_changed signal (for mana updates only - don't rebuild UI)
if local_player.character_stats.mana_changed.is_connected(_on_mana_changed):
local_player.character_stats.mana_changed.disconnect(_on_mana_changed)
local_player.character_stats.mana_changed.connect(_on_mana_changed)
# Connect to health_changed signal (for HP updates only - don't rebuild UI)
if local_player.character_stats.health_changed.is_connected(_on_health_changed):
local_player.character_stats.health_changed.disconnect(_on_health_changed)
local_player.character_stats.health_changed.connect(_on_health_changed)
# Initial update # Initial update
_update_ui() _update_ui()
_update_stats() _update_stats()
@@ -1396,6 +1404,20 @@ func _on_inventory_item_gui_input(event: InputEvent, item: Item):
# Use the same logic as E key to drop # Use the same logic as E key to drop
_handle_e_key() _handle_e_key()
func _on_mana_changed(new_mana: float, max_mana: float):
# Update only MP bar and label when mana changes (don't rebuild entire UI)
if mp_progress_bar and mp_value_label:
mp_progress_bar.max_value = max(1.0, max_mana)
mp_progress_bar.value = new_mana
mp_value_label.text = str(int(new_mana)) + "/" + str(int(max_mana))
func _on_health_changed(new_hp: float, max_hp: float):
# Update only HP bar and label when health changes (don't rebuild entire UI)
if hp_progress_bar and hp_value_label:
hp_progress_bar.max_value = max(1.0, max_hp)
hp_progress_bar.value = new_hp
hp_value_label.text = str(int(new_hp)) + "/" + str(int(max_hp))
func _on_character_changed(_char: CharacterStats): func _on_character_changed(_char: CharacterStats):
# Always update stats when character changes (even if inventory is closed) # Always update stats when character changes (even if inventory is closed)
# Equipment changes affect max HP/MP which should be reflected everywhere # Equipment changes affect max HP/MP which should be reflected everywhere

View File

@@ -1664,6 +1664,45 @@ static func _load_all_items():
"rarity": ItemRarity.COMMON "rarity": ItemRarity.COMMON
}) })
_register_item("apple", {
"item_name": "Apple",
"description": "Restores 20 HP",
"item_type": Item.ItemType.Restoration,
"equipment_type": Item.EquipmentType.NONE,
"weapon_type": Item.WeaponType.NONE,
"spriteFrame": 8 * 20 + 10, # 10,8
"modifiers": {"hp": 20},
"buy_cost": 15,
"sell_worth": 5,
"rarity": ItemRarity.COMMON
})
_register_item("banana", {
"item_name": "Banana",
"description": "Restores 20 HP",
"item_type": Item.ItemType.Restoration,
"equipment_type": Item.EquipmentType.NONE,
"weapon_type": Item.WeaponType.NONE,
"spriteFrame": 8 * 20 + 11, # 11,8
"modifiers": {"hp": 20},
"buy_cost": 15,
"sell_worth": 5,
"rarity": ItemRarity.COMMON
})
_register_item("cherry", {
"item_name": "Cherry",
"description": "Restores 20 HP",
"item_type": Item.ItemType.Restoration,
"equipment_type": Item.EquipmentType.NONE,
"weapon_type": Item.WeaponType.NONE,
"spriteFrame": 8 * 20 + 12, # 12,8
"modifiers": {"hp": 20},
"buy_cost": 15,
"sell_worth": 5,
"rarity": ItemRarity.COMMON
})
_register_item("healing_potion", { _register_item("healing_potion", {
"item_name": "Healing Potion", "item_name": "Healing Potion",
"description": "Restores 50 HP", "description": "Restores 50 HP",
@@ -1945,6 +1984,106 @@ static func get_random_item() -> Item:
return get_random_item_by_rarity(rarity) return get_random_item_by_rarity(rarity)
# Get random consumable items (arrows, bombs, restoration items) for enemy drops
static func get_random_consumable_drop() -> Item:
_initialize()
# Prioritize consumables: 70% CONSUMABLE rarity, 30% Restoration items
var roll = randf()
if roll < 0.7:
# CONSUMABLE rarity items (arrows, bombs, etc.)
return get_random_item_by_rarity(ItemRarity.CONSUMABLE)
else:
# Restoration items (food) - get from COMMON rarity with Restoration type
var candidates = []
var weights = []
var total_weight = 0.0
for item_id in item_definitions.keys():
var item_data = item_definitions[item_id]
if item_data.has("rarity") and item_data["rarity"] == ItemRarity.COMMON:
if item_data.has("item_type") and item_data["item_type"] == Item.ItemType.Restoration:
candidates.append(item_id)
var drop_chance = item_data.get("drop_chance", 1.0)
weights.append(drop_chance)
total_weight += drop_chance
if candidates.is_empty():
# Fallback to CONSUMABLE rarity
return get_random_item_by_rarity(ItemRarity.CONSUMABLE)
# Weighted random selection
var weight_roll = randf() * total_weight
var cumulative = 0.0
for i in range(candidates.size()):
cumulative += weights[i]
if weight_roll <= cumulative:
return create_item(candidates[i])
# Fallback
return create_item(candidates[candidates.size() - 1])
# Get random equipment items (much rarer than consumables)
static func get_random_equipment_drop() -> Item:
_initialize()
# Equipment: 70% common, 25% uncommon, 4% rare, 1% epic
var roll = randf()
var rarity: ItemRarity
if roll < 0.7:
rarity = ItemRarity.COMMON
elif roll < 0.95:
rarity = ItemRarity.UNCOMMON
elif roll < 0.99:
rarity = ItemRarity.RARE
else:
rarity = ItemRarity.EPIC
# Filter to only Equippable items of the selected rarity
var candidates = []
var weights = []
var total_weight = 0.0
for item_id in item_definitions.keys():
var item_data = item_definitions[item_id]
if item_data.has("rarity") and item_data["rarity"] == rarity:
# Only include Equippable items (not consumables or restoration)
if item_data.has("item_type") and item_data["item_type"] == Item.ItemType.Equippable:
candidates.append(item_id)
var drop_chance = item_data.get("drop_chance", 1.0)
weights.append(drop_chance)
total_weight += drop_chance
if candidates.is_empty():
# Fallback: try lower rarity equipment
if rarity != ItemRarity.COMMON:
# Try common equipment as fallback
for item_id in item_definitions.keys():
var item_data = item_definitions[item_id]
if item_data.has("rarity") and item_data["rarity"] == ItemRarity.COMMON:
if item_data.has("item_type") and item_data["item_type"] == Item.ItemType.Equippable:
candidates.append(item_id)
var drop_chance = item_data.get("drop_chance", 1.0)
weights.append(drop_chance)
total_weight += drop_chance
if candidates.is_empty():
# Last resort: return any common item
return get_random_item_by_rarity(ItemRarity.COMMON)
# Weighted random selection
var weight_roll = randf() * total_weight
var cumulative = 0.0
for i in range(candidates.size()):
cumulative += weights[i]
if weight_roll <= cumulative:
return create_item(candidates[i])
# Fallback
return create_item(candidates[candidates.size() - 1])
# Get random items for enemies (weighted towards common/uncommon) # Get random items for enemies (weighted towards common/uncommon)
static func get_random_enemy_drop() -> Item: static func get_random_enemy_drop() -> Item:
_initialize() _initialize()

View File

@@ -86,6 +86,10 @@ func _ready():
# Setup sprite based on loot type (call after all properties are set) # Setup sprite based on loot type (call after all properties are set)
call_deferred("_setup_sprite") call_deferred("_setup_sprite")
# CRITICAL: Duplicate sprite material after setup so it isn't shared between loot instances
# This must be called after _setup_sprite because materials may be applied there (e.g., ItemDatabase.apply_item_colors_to_sprite)
call_deferred("_duplicate_sprite_material")
# Setup collision shape based on loot type # Setup collision shape based on loot type
call_deferred("_setup_collision_shape") call_deferred("_setup_collision_shape")
@@ -187,6 +191,12 @@ func _setup_collision_shape():
collision_shape.shape = circle_shape collision_shape.shape = circle_shape
func _duplicate_sprite_material():
# Duplicate sprite material so it isn't shared between loot instances
# This prevents material state from being shared (e.g., colorization, tint effects)
if sprite and sprite.material:
sprite.material = sprite.material.duplicate()
func _create_quantity_badge(quantity: int): func _create_quantity_badge(quantity: int):
# Create a label to show the quantity # Create a label to show the quantity
quantity_badge = Label.new() quantity_badge = Label.new()
@@ -515,19 +525,40 @@ func _process_pickup_on_server(player: Node):
LootType.APPLE: LootType.APPLE:
if sfx_potion_collect: if sfx_potion_collect:
sfx_potion_collect.play() sfx_potion_collect.play()
# Heal player
var actual_heal = 0.0 # Create Item instance and add to inventory instead of directly healing
if player.has_method("heal"): var apple_item = ItemDatabase.create_item("apple")
actual_heal = heal_amount if apple_item and player.character_stats:
player.heal(heal_amount) var was_encumbered = player.character_stats.is_over_encumbered()
# Show floating text with item graphic and heal amount player.character_stats.add_item(apple_item)
if not was_encumbered and player.character_stats.is_over_encumbered():
if player.has_method("show_floating_status"):
player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2))
# Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply)
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var owner_id = player.get_multiplayer_authority()
if owner_id != 1 and owner_id != multiplayer.get_unique_id():
var inv_data: Array = []
for inv_item in player.character_stats.inventory:
inv_data.append(inv_item.save() if inv_item else null)
var equip_data: Dictionary = {}
for slot_name in player.character_stats.equipment.keys():
var eq = player.character_stats.equipment[slot_name]
equip_data[slot_name] = eq.save() if eq else null
if player.has_method("_apply_inventory_and_equipment_from_server"):
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase)
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
_show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) var display_text = "APPLE"
var text_color = Color.GREEN
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10)
# Sync floating text to client via GameWorld to avoid loot node path RPCs # Sync floating text to client via GameWorld to avoid loot node path RPCs
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_loot_floating_text"): if game_world and game_world.has_method("_sync_loot_floating_text"):
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 10, player.get_multiplayer_authority()) game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority())
self.visible = false self.visible = false
@@ -538,19 +569,40 @@ func _process_pickup_on_server(player: Node):
LootType.BANANA: LootType.BANANA:
if sfx_banana_collect: if sfx_banana_collect:
sfx_banana_collect.play() sfx_banana_collect.play()
# Heal player
var actual_heal = 0.0 # Create Item instance and add to inventory instead of directly healing
if player.has_method("heal"): var banana_item = ItemDatabase.create_item("banana")
actual_heal = heal_amount if banana_item and player.character_stats:
player.heal(heal_amount) var was_encumbered = player.character_stats.is_over_encumbered()
# Show floating text with item graphic and heal amount player.character_stats.add_item(banana_item)
if not was_encumbered and player.character_stats.is_over_encumbered():
if player.has_method("show_floating_status"):
player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2))
# Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply)
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var owner_id = player.get_multiplayer_authority()
if owner_id != 1 and owner_id != multiplayer.get_unique_id():
var inv_data: Array = []
for inv_item in player.character_stats.inventory:
inv_data.append(inv_item.save() if inv_item else null)
var equip_data: Dictionary = {}
for slot_name in player.character_stats.equipment.keys():
var eq = player.character_stats.equipment[slot_name]
equip_data[slot_name] = eq.save() if eq else null
if player.has_method("_apply_inventory_and_equipment_from_server"):
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase)
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
_show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) var display_text = "BANANA"
var text_color = Color.GREEN
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11)
# Sync floating text to client via GameWorld to avoid loot node path RPCs # Sync floating text to client via GameWorld to avoid loot node path RPCs
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_loot_floating_text"): if game_world and game_world.has_method("_sync_loot_floating_text"):
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 11, player.get_multiplayer_authority()) game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority())
self.visible = false self.visible = false
@@ -561,19 +613,40 @@ func _process_pickup_on_server(player: Node):
LootType.CHERRY: LootType.CHERRY:
if sfx_banana_collect: if sfx_banana_collect:
sfx_banana_collect.play() sfx_banana_collect.play()
# Heal player
var actual_heal = 0.0 # Create Item instance and add to inventory instead of directly healing
if player.has_method("heal"): var cherry_item = ItemDatabase.create_item("cherry")
actual_heal = heal_amount if cherry_item and player.character_stats:
player.heal(heal_amount) var was_encumbered = player.character_stats.is_over_encumbered()
# Show floating text with item graphic and heal amount player.character_stats.add_item(cherry_item)
if not was_encumbered and player.character_stats.is_over_encumbered():
if player.has_method("show_floating_status"):
player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2))
# Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply)
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var owner_id = player.get_multiplayer_authority()
if owner_id != 1 and owner_id != multiplayer.get_unique_id():
var inv_data: Array = []
for inv_item in player.character_stats.inventory:
inv_data.append(inv_item.save() if inv_item else null)
var equip_data: Dictionary = {}
for slot_name in player.character_stats.equipment.keys():
var eq = player.character_stats.equipment[slot_name]
equip_data[slot_name] = eq.save() if eq else null
if player.has_method("_apply_inventory_and_equipment_from_server"):
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase)
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
_show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) var display_text = "CHERRY"
var text_color = Color.GREEN
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12)
# Sync floating text to client via GameWorld to avoid loot node path RPCs # Sync floating text to client via GameWorld to avoid loot node path RPCs
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_loot_floating_text"): if game_world and game_world.has_method("_sync_loot_floating_text"):
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 12, player.get_multiplayer_authority()) game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority())
self.visible = false self.visible = false

View File

@@ -354,6 +354,20 @@ func _handle_new_peer(uuid: String):
# Just store the UUID for now, peer ID will come via Signal message # Just store the UUID for now, peer ID will come via Signal message
log_print("MatchboxClient: Client received NewPeer for UUID: " + uuid + " (waiting for host to assign peer ID)") log_print("MatchboxClient: Client received NewPeer for UUID: " + uuid + " (waiting for host to assign peer ID)")
# If we're in reconnection mode and don't have a host connection yet, this might be the host reconnecting
# Check if we previously had a host (peer ID 1) but lost it
var had_host = false
for stored_uuid in peer_uuid_to_id:
if peer_uuid_to_id[stored_uuid] == 1:
had_host = true
break
# If we don't have a host connection and we're waiting for reconnection, this could be the host
if not had_host and not peer_connections.has(1):
log_print("MatchboxClient: Client received NewPeer while waiting for host reconnection - this might be the host reconnecting")
# Don't do anything yet - wait for host to assign peer ID via Signal
# But we can clear any reconnection flags in NetworkManager if needed
func _handle_peer_left_uuid(uuid: String): func _handle_peer_left_uuid(uuid: String):
if uuid.is_empty(): if uuid.is_empty():
return return
@@ -585,6 +599,14 @@ func _handle_signal_message(peer_id: int, signal_data: Dictionary):
_handle_signal_message_dict(queued_msg) _handle_signal_message_dict(queued_msg)
queued_signaling_messages.clear() queued_signaling_messages.clear()
# If we're in reconnection mode, clear it since we've successfully reconnected
var network_manager = get_parent()
if network_manager and "reconnection_attempting" in network_manager:
if network_manager.reconnection_attempting:
log_print("MatchboxClient: Host reconnected and assigned peer ID - clearing reconnection state")
network_manager.reconnection_attempting = false
network_manager.reconnection_timer = 0.0
return return
# Handle WebRTC signaling messages (offer, answer, ice-candidate) # Handle WebRTC signaling messages (offer, answer, ice-candidate)

View File

@@ -519,6 +519,15 @@ func _on_matchbox_connected(was_reconnecting: bool = false):
if chat_ui and chat_ui.has_method("add_colorful_local_message"): if chat_ui and chat_ui.has_method("add_colorful_local_message"):
chat_ui.add_colorful_local_message("System", "Matchbox connection re-established!") chat_ui.add_colorful_local_message("System", "Matchbox connection re-established!")
# For joiners: if we're connected to Matchbox but in reconnection mode,
# we're waiting for the host to reconnect and assign our peer ID
# Don't clear reconnection state yet - wait for peer ID assignment
if not is_hosting and reconnection_attempting and matchbox_client:
if matchbox_client.is_network_connected:
log_print("NetworkManager: Joiner connected to Matchbox, waiting for host to reconnect and assign peer ID...")
# Don't clear reconnection_attempting yet - wait for peer ID assignment
# The matchbox_client will clear it when peer ID is assigned
func _on_matchbox_webrtc_ready(): func _on_matchbox_webrtc_ready():
log_print("NetworkManager: WebRTC mesh is ready") log_print("NetworkManager: WebRTC mesh is ready")

View File

@@ -120,6 +120,8 @@ var flame_spell_scene = preload("res://scenes/attack_spell_flame.tscn") # Flame
var frostspike_spell_scene = preload("res://scenes/attack_spell_frostspike.tscn") # Frostspike for Tome of Frostspike var frostspike_spell_scene = preload("res://scenes/attack_spell_frostspike.tscn") # Frostspike for Tome of Frostspike
var healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing var healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing
var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile
var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch
var attack_axe_swing_scene = preload("res://scenes/attack_axe_swing.tscn") # Axe swing (orbits)
var blood_scene = preload("res://scenes/blood_clot.tscn") var blood_scene = preload("res://scenes/blood_clot.tscn")
# Simulated Z-axis for height (when thrown) # Simulated Z-axis for height (when thrown)
@@ -139,6 +141,7 @@ var spawn_landing: bool = false
var spawn_landing_landed: bool = false var spawn_landing_landed: bool = false
var spawn_landing_bounced: bool = false var spawn_landing_bounced: bool = false
var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling
var has_seen_exit_this_level: bool = false # Track if player has seen exit notification for current level
# Components # Components
# @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites # @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites
@@ -237,7 +240,7 @@ const ANIMATIONS = {
}, },
"PUNCH": { "PUNCH": {
"frames": [16, 17, 18], "frames": [16, 17, 18],
"frameDurations": [50, 70, 100], "frameDurations": [60, 90, 120],
"loop": false, "loop": false,
"nextAnimation": "IDLE" "nextAnimation": "IDLE"
}, },
@@ -386,7 +389,7 @@ func _ready():
if cone_light: if cone_light:
cone_light.visible = is_local_player cone_light.visible = is_local_player
if point_light: if point_light:
point_light.visible = is_local_player point_light.visible = true # Show point light for all joiners (cone is local-only)
elif is_local_player: elif is_local_player:
# Local players (initial spawn only): hide until right before fall-from-sky # Local players (initial spawn only): hide until right before fall-from-sky
visible = false visible = false
@@ -407,9 +410,8 @@ func _ready():
visible = true visible = true
spawn_landing = false spawn_landing = false
if cone_light: if cone_light:
cone_light.visible = false cone_light.visible = false # Don't show other players' cone lights
if point_light: # point_light stays visible for other players
point_light.visible = false
# Set respawn point to starting position # Set respawn point to starting position
respawn_point = global_position respawn_point = global_position
@@ -789,15 +791,25 @@ func _randomize_stats():
func _setup_player_appearance(): func _setup_player_appearance():
# Randomize appearance - players spawn "bare" (naked, no equipment) # Randomize appearance - players spawn "bare" (naked, no equipment)
# But with randomized hair, facial hair, eyes, etc. # But with randomized hair, facial hair, eyes, etc.
# Ensure character_stats exists before setting appearance # Ensure character_stats exists before setting appearance
if not character_stats: if not character_stats:
LogManager.log_error("Player " + str(name) + " _setup_player_appearance: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) LogManager.log_error("Player " + str(name) + " _setup_player_appearance: character_stats is null!", LogManager.CATEGORY_GAMEPLAY)
return return
# Randomize race first (affects appearance constraints and stats) # Use race from select screen if set (local player only); otherwise randomize (affects appearance and stats)
var races = ["Dwarf", "Elf", "Human"] var races = ["Dwarf", "Elf", "Human"]
var selected_race = races[appearance_rng.randi() % races.size()] var selected_race: String
var gs_race_read: String = ""
if is_local_player:
var gs = get_node_or_null("/root/GameState")
if gs:
gs_race_read = gs.selected_race
if gs.selected_race != "" and gs.selected_race in races:
selected_race = gs.selected_race
if selected_race.is_empty():
selected_race = races[appearance_rng.randi() % races.size()]
# Log what joiner/local player was made (authority runs this; joiner's client runs it for joiner's player)
print("Player ", name, " _setup_player_appearance: peer_id=", peer_id, " is_local_player=", is_local_player, " is_authority=", is_multiplayer_authority(), " GameState.selected_race='", gs_race_read, "' -> USING race='", selected_race, "'")
character_stats.setRace(selected_race) character_stats.setRace(selected_race)
# Randomize stats AFTER race is set (race affects stat modifiers) # Randomize stats AFTER race is set (race affects stat modifiers)
@@ -813,23 +825,38 @@ func _setup_player_appearance():
character_stats.equipment["offhand"] = starting_arrows character_stats.equipment["offhand"] = starting_arrows
print("Elf player ", name, " spawned with short bow and 3 arrows") print("Elf player ", name, " spawned with short bow and 3 arrows")
# Give Dwarf race starting bomb # Give Dwarf race starting bomb + debug weapons in inventory (axe, dagger/knife, sword)
if selected_race == "Dwarf": if selected_race == "Dwarf":
var starting_bomb = ItemDatabase.create_item("bomb") var starting_bomb = ItemDatabase.create_item("bomb")
if starting_bomb: if starting_bomb:
starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start
character_stats.equipment["offhand"] = starting_bomb character_stats.equipment["offhand"] = starting_bomb
print("Dwarf player ", name, " spawned with 5 bombs") var debug_axe = ItemDatabase.create_item("axe")
if debug_axe:
character_stats.add_item(debug_axe)
var debug_dagger = ItemDatabase.create_item("knife")
if debug_dagger:
character_stats.add_item(debug_dagger)
var debug_sword = ItemDatabase.create_item("short_sword")
if debug_sword:
character_stats.add_item(debug_sword)
print("Dwarf player ", name, " spawned with 5 bombs and debug axe/dagger/sword in inventory")
# Give Human race (Wizard) starting spellbook (Tome of Flames) and Hat # Give Human race (Wizard) starting spellbook (Tome of Flames), Tome of Healing, Tome of Frostspike, and Hat
if selected_race == "Human": if selected_race == "Human":
var starting_tome = ItemDatabase.create_item("tome_of_flames") var starting_tome = ItemDatabase.create_item("tome_of_flames")
if starting_tome: if starting_tome:
character_stats.equipment["offhand"] = starting_tome character_stats.equipment["offhand"] = starting_tome
var tome_healing = ItemDatabase.create_item("tome_of_healing")
if tome_healing:
character_stats.add_item(tome_healing)
var tome_frostspike = ItemDatabase.create_item("tome_of_frostspike")
if tome_frostspike:
character_stats.add_item(tome_frostspike)
var starting_hat = ItemDatabase.create_item("hat") var starting_hat = ItemDatabase.create_item("hat")
if starting_hat: if starting_hat:
character_stats.equipment["headgear"] = starting_hat character_stats.equipment["headgear"] = starting_hat
print("Human player ", name, " spawned with Tome of Flames and Hat") print("Human player ", name, " spawned with Tome of Flames, Tome of Healing, Tome of Frostspike, and Hat")
# 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
@@ -1359,6 +1386,7 @@ func _on_character_changed(_char: CharacterStats):
# ALWAYS sync race and base stats to all clients (for proper display) # ALWAYS sync race and base stats to all clients (for proper display)
# This ensures new clients get appearance data even if they connect after initial setup # This ensures new clients get appearance data even if they connect after initial setup
print("Player ", name, " (authority) SENDING _sync_race_and_stats to all peers: race='", character_stats.race, "'")
_rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()]) _rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()])
# Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players # Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players
@@ -1906,6 +1934,13 @@ func _physics_process(delta):
if is_airborne: if is_airborne:
_update_z_physics(delta) _update_z_physics(delta)
# Mana regeneration (slowly regain mana over time)
if character_stats and is_multiplayer_authority():
# Regenerate 2 mana per second (slow regeneration)
const MANA_REGEN_RATE = 2.0 # mana per second
if character_stats.mp < character_stats.maxmp:
character_stats.restore_mana(MANA_REGEN_RATE * delta)
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse # Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
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
@@ -2016,7 +2051,9 @@ func _physics_process(delta):
if character_stats: if character_stats:
var old_hp = character_stats.hp var old_hp = character_stats.hp
character_stats.modify_health(-burn_debuff_damage_per_second) character_stats.modify_health(-burn_debuff_damage_per_second)
if character_stats.hp <= 0: # Check if dead (use epsilon to handle floating point precision)
if character_stats.hp <= 0.001:
character_stats.hp = 0.0 # Ensure exactly 0
character_stats.no_health.emit() character_stats.no_health.emit()
character_stats.character_changed.emit(character_stats) character_stats.character_changed.emit(character_stats)
var actual_damage = old_hp - character_stats.hp var actual_damage = old_hp - character_stats.hp
@@ -2058,7 +2095,7 @@ func _physics_process(delta):
# Exception: entrance walk-out - velocity is driven by game_world for cut-scene # Exception: entrance walk-out - velocity is driven by game_world for cut-scene
velocity = Vector2.ZERO velocity = Vector2.ZERO
# Reset animation to IDLE if not in a special state (skip when spawn_landing: we use DIE until stand up) # Reset animation to IDLE if not in a special state (skip when spawn_landing: we use DIE until stand up)
if not spawn_landing and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": if not spawn_landing and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH":
if is_lifting: if is_lifting:
_set_animation("IDLE_HOLD") _set_animation("IDLE_HOLD")
elif is_pushing: elif is_pushing:
@@ -2381,7 +2418,7 @@ func _handle_input():
_set_animation("RUN_PULL") _set_animation("RUN_PULL")
else: else:
_set_animation("RUN_PUSH") _set_animation("RUN_PUSH")
elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL":
_set_animation("RUN") _set_animation("RUN")
else: else:
# Idle animations # Idle animations
@@ -2412,7 +2449,7 @@ func _handle_input():
current_direction = new_direction current_direction = new_direction
_update_cone_light_rotation() _update_cone_light_rotation()
else: else:
if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL":
_set_animation("IDLE") _set_animation("IDLE")
# Handle drag sound for interactable objects # Handle drag sound for interactable objects
@@ -2447,7 +2484,7 @@ func _handle_input():
elif is_charging_bow: elif is_charging_bow:
speed_multiplier = 0.5 speed_multiplier = 0.5
elif is_charging_spell: elif is_charging_spell:
speed_multiplier = 0.2 # 20% speed (80% reduction) speed_multiplier = 0.5 # 50% speed (50% reduction)
elif is_shielding: elif is_shielding:
speed_multiplier = 0.6 # 60% speed when blocking with shield speed_multiplier = 0.6 # 60% speed when blocking with shield
@@ -2640,6 +2677,24 @@ func _handle_interactions():
break break
if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object: if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object:
# Check if player has enough mana before starting to charge
var has_enough_mana = false
if character_stats:
if is_fire:
has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost
elif is_frost:
has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost
else:
has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost
if not has_enough_mana:
# Not enough mana - show message to local player only
if is_local_player:
_show_not_enough_mana_text()
print(name, " cannot start charging spell - not enough mana")
just_grabbed_this_frame = false
return
is_charging_spell = true is_charging_spell = true
current_spell_element = "healing" if is_heal else ("frost" if is_frost else "fire") current_spell_element = "healing" if is_heal else ("frost" if is_frost else "fire")
spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_charge_start_time = Time.get_ticks_msec() / 1000.0
@@ -2681,12 +2736,40 @@ func _handle_interactions():
has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null)
if has_valid_target and is_fully_charged: if has_valid_target and is_fully_charged:
# Check if player has enough mana before casting
var has_enough_mana = false
if character_stats:
if is_fire:
has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost
elif is_frost:
has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost
else:
has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost
if has_enough_mana:
if is_fire: if is_fire:
_cast_flame_spell(target_pos) _cast_flame_spell(target_pos)
elif is_frost: elif is_frost:
_cast_frostspike_spell(target_pos) _cast_frostspike_spell(target_pos)
else: else:
_cast_heal_spell(heal_target) _cast_heal_spell(heal_target)
else:
# Not enough mana - cancel spell
print(name, " cannot cast spell - not enough mana")
is_charging_spell = false
current_spell_element = "fire"
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
_set_animation("IDLE")
if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop()
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
return
_set_animation("FINISH_SPELL") _set_animation("FINISH_SPELL")
movement_lock_timer = SPELL_CAST_LOCK_DURATION movement_lock_timer = SPELL_CAST_LOCK_DURATION
is_charging_spell = false is_charging_spell = false
@@ -2853,6 +2936,14 @@ func _handle_interactions():
# 2. Button is still down (shouldn't happen, but safety check) # 2. Button is still down (shouldn't happen, but safety check)
# 3. grab_just_pressed is also true (same frame tap) # 3. grab_just_pressed is also true (same frame tap)
if grab_just_released: if grab_just_released:
# Stop reviving if was reviving
if is_reviving:
is_reviving = false
revive_charge = 0.0
# Sync revive end to all clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_sync_revive_end.rpc()
else:
is_reviving = false is_reviving = false
revive_charge = 0.0 revive_charge = 0.0
@@ -2920,17 +3011,33 @@ func _handle_interactions():
var holding_dead_player = _is_player(held_object) and "is_dead" in held_object and held_object.is_dead var holding_dead_player = _is_player(held_object) and "is_dead" in held_object and held_object.is_dead
var reviver_hp = character_stats.hp if character_stats else 1.0 var reviver_hp = character_stats.hp if character_stats else 1.0
if holding_dead_player and reviver_hp > 1.0: if holding_dead_player and reviver_hp > 1.0:
# Start reviving if not already
if not is_reviving:
is_reviving = true is_reviving = true
# Sync revive start to all clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_sync_revive_start.rpc()
revive_charge += get_process_delta_time() revive_charge += get_process_delta_time()
if revive_charge >= REVIVE_DURATION: if revive_charge >= REVIVE_DURATION:
_do_revive(held_object) _do_revive(held_object)
_place_down_object() _place_down_object()
is_reviving = false is_reviving = false
revive_charge = 0.0 revive_charge = 0.0
# Sync revive end to all clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_sync_revive_end.rpc()
else: else:
_update_lifted_object() _update_lifted_object()
else: else:
if holding_dead_player: if holding_dead_player:
# Stop reviving if was reviving
if is_reviving:
is_reviving = false
revive_charge = 0.0
# Sync revive end to all clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_sync_revive_end.rpc()
else:
is_reviving = false is_reviving = false
revive_charge = 0.0 revive_charge = 0.0
_update_lifted_object() _update_lifted_object()
@@ -3843,17 +3950,25 @@ func _perform_attack():
var is_bow = false var is_bow = false
var is_staff = false var is_staff = false
var is_axe = false
var is_unarmed = (equipped_weapon == null)
if equipped_weapon: if equipped_weapon:
if equipped_weapon.weapon_type == Item.WeaponType.BOW: if equipped_weapon.weapon_type == Item.WeaponType.BOW:
is_bow = true is_bow = true
elif equipped_weapon.weapon_type == Item.WeaponType.STAFF: elif equipped_weapon.weapon_type == Item.WeaponType.STAFF:
is_staff = true is_staff = true
elif equipped_weapon.weapon_type == Item.WeaponType.AXE:
is_axe = true
# Play attack animation based on weapon # Play attack animation based on weapon (PUNCH when no mainhand)
if is_bow: if is_bow:
_set_animation("BOW") _set_animation("BOW")
elif is_staff: elif is_staff:
_set_animation("STAFF") _set_animation("STAFF")
elif is_axe:
_set_animation("AXE")
elif is_unarmed:
_set_animation("PUNCH")
else: else:
_set_animation("SWORD") _set_animation("SWORD")
@@ -3961,8 +4076,31 @@ func _perform_attack():
var spawn_offset = attack_direction * 6.0 var spawn_offset = attack_direction * 6.0
projectile.global_position = global_position + spawn_offset projectile.global_position = global_position + spawn_offset
print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
elif is_axe:
# Axe swing - stays on player, plays directional animation
if attack_axe_swing_scene and equipped_weapon:
spawned_projectile_type = "axe"
var axe_swing = attack_axe_swing_scene.instantiate()
get_parent().add_child(axe_swing)
axe_swing.setup(attack_direction, self, -1.0, equipped_weapon)
axe_swing.global_position = global_position
print(name, " axe swing! Damage: ", final_damage)
elif is_unarmed:
# Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage)
if attack_punch_scene:
spawned_projectile_type = "punch"
var punch_damage = 2.0
if character_stats:
var str_total = character_stats.baseStats.str + character_stats.get_pass("str")
punch_damage = 2.0 + str_total * 0.1
punch_damage = max(1.0, round(punch_damage * 10.0) / 10.0)
var punch = attack_punch_scene.instantiate()
get_parent().add_child(punch)
punch.setup(attack_direction, self, punch_damage)
punch.global_position = global_position + attack_direction * 12.0
print(name, " punched! Damage: ", punch_damage)
else: else:
# Spawn sword projectile for non-bow/staff weapons # Spawn sword projectile for non-bow/staff/axe weapons
if sword_projectile_scene: if sword_projectile_scene:
spawned_projectile_type = "sword" spawned_projectile_type = "sword"
var projectile = sword_projectile_scene.instantiate() var projectile = sword_projectile_scene.instantiate()
@@ -4248,6 +4386,14 @@ func _cast_flame_spell(target_position: Vector2):
if not is_multiplayer_authority(): if not is_multiplayer_authority():
return return
# Check mana cost (15 mana for flame spell)
const FLAME_SPELL_MANA_COST = 15.0
if not character_stats:
return
if not character_stats.use_mana(FLAME_SPELL_MANA_COST):
print(name, " cannot cast flame spell - not enough mana (need ", FLAME_SPELL_MANA_COST, ", have ", character_stats.mp, ")")
return
# Find valid spell target position (closest valid if target is blocked) # Find valid spell target position (closest valid if target is blocked)
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
var valid_target_pos = target_position var valid_target_pos = target_position
@@ -4256,7 +4402,9 @@ func _cast_flame_spell(target_position: Vector2):
if found_pos != Vector2.ZERO: if found_pos != Vector2.ZERO:
valid_target_pos = found_pos valid_target_pos = found_pos
else: else:
# No valid position found, cancel spell # No valid position found, cancel spell and refund mana
if character_stats:
character_stats.restore_mana(FLAME_SPELL_MANA_COST)
print(name, " cannot cast spell - no valid target position") print(name, " cannot cast spell - no valid target position")
return return
@@ -4301,6 +4449,15 @@ func _cast_frostspike_spell(target_position: Vector2):
return return
if not is_multiplayer_authority(): if not is_multiplayer_authority():
return return
# Check mana cost (15 mana for frostspike spell)
const FROSTSPIKE_SPELL_MANA_COST = 15.0
if not character_stats:
return
if not character_stats.use_mana(FROSTSPIKE_SPELL_MANA_COST):
print(name, " cannot cast frostspike - not enough mana (need ", FROSTSPIKE_SPELL_MANA_COST, ", have ", character_stats.mp, ")")
return
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
var valid_target_pos = target_position var valid_target_pos = target_position
if game_world and game_world.has_method("_get_valid_spell_target_position"): if game_world and game_world.has_method("_get_valid_spell_target_position"):
@@ -4308,6 +4465,9 @@ func _cast_frostspike_spell(target_position: Vector2):
if found_pos != Vector2.ZERO: if found_pos != Vector2.ZERO:
valid_target_pos = found_pos valid_target_pos = found_pos
else: else:
# No valid position found, cancel spell and refund mana
if character_stats:
character_stats.restore_mana(FROSTSPIKE_SPELL_MANA_COST)
print(name, " cannot cast frostspike - no valid target position") print(name, " cannot cast frostspike - no valid target position")
return return
var spell_damage = 15.0 var spell_damage = 15.0
@@ -4336,6 +4496,12 @@ func _cast_heal_spell(target: Node):
return return
if not character_stats: if not character_stats:
return return
# Check mana cost (20 mana for heal spell - more expensive since it's healing)
const HEAL_SPELL_MANA_COST = 20.0
if not character_stats.use_mana(HEAL_SPELL_MANA_COST):
print(name, " cannot cast heal spell - not enough mana (need ", HEAL_SPELL_MANA_COST, ", have ", character_stats.mp, ")")
return
var gw = get_tree().get_first_node_in_group("game_world") var gw = get_tree().get_first_node_in_group("game_world")
var dungeon_seed: int = 0 var dungeon_seed: int = 0
if gw and "dungeon_seed" in gw: if gw and "dungeon_seed" in gw:
@@ -4522,7 +4688,6 @@ func _get_heal_target() -> Node:
func _can_cast_spell_at(target_position: Vector2) -> bool: func _can_cast_spell_at(target_position: Vector2) -> bool:
# Check if spell can be cast at target position # Check if spell can be cast at target position
# Must be on floor tile and not blocked by walls # Must be on floor tile and not blocked by walls
# Get game world for dungeon data # Get game world for dungeon data
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world: if not game_world:
@@ -4941,6 +5106,28 @@ func _sync_spell_charge_end():
print(name, " (synced) ended charging spell") print(name, " (synced) ended charging spell")
@rpc("any_peer", "reliable")
func _sync_revive_start():
# Sync revive start to other clients - show AnimationIncantation effect
if not is_multiplayer_authority():
is_reviving = true
revive_charge = 0.0
# Play healing_charging animation on AnimationIncantation
if has_node("AnimationIncantation") and not is_charging_spell:
$AnimationIncantation.play("healing_charging")
print(name, " (synced) started reviving")
@rpc("any_peer", "reliable")
func _sync_revive_end():
# Sync revive end to other clients - stop AnimationIncantation effect
if not is_multiplayer_authority():
is_reviving = false
revive_charge = 0.0
# Stop healing_charging animation
if has_node("AnimationIncantation") and not is_charging_spell:
_stop_spell_charge_incantation()
print(name, " (synced) stopped reviving")
func _apply_burn_debuff(): func _apply_burn_debuff():
# Apply burn debuff to player # Apply burn debuff to player
var was_already_burning = burn_debuff_timer > 0.0 var was_already_burning = burn_debuff_timer > 0.0
@@ -5184,6 +5371,40 @@ func _rpc_to_ready_peers(method: String, args: Array = []):
else: else:
callv("rpc", [method] + args) callv("rpc", [method] + args)
# Push this player's full state (equipment, inventory, race, appearance) to a single peer.
# Used when a new joiner connects so they receive the host's (and other existing players') state.
func _push_full_state_to_peer(target_peer_id: int) -> void:
if not is_multiplayer_authority() or not character_stats or not is_inside_tree():
return
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
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
var appearance_data = {
"skin": character_stats.skin,
"hairstyle": character_stats.hairstyle,
"hair_color": character_stats.hair_color.to_html(true),
"facial_hair": character_stats.facial_hair,
"facial_hair_color": character_stats.facial_hair_color.to_html(true),
"eyes": character_stats.eyes,
"eye_color": character_stats.eye_color.to_html(true),
"eye_lashes": character_stats.eye_lashes,
"eyelash_color": character_stats.eyelash_color.to_html(true),
"add_on": character_stats.add_on
}
_sync_equipment.rpc_id(target_peer_id, equipment_data)
_sync_inventory.rpc_id(target_peer_id, inventory_data)
_sync_race_and_stats.rpc_id(target_peer_id, character_stats.race, character_stats.baseStats.duplicate())
_sync_appearance.rpc_id(target_peer_id, appearance_data)
print(name, " pushed full state (equipment, inventory, race, appearance) to peer ", target_peer_id, " inventory size: ", inventory_data.size())
func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: func _is_peer_connected_for_rpc(target_peer_id: int) -> bool:
"""Check if a peer is still connected and has open data channels before sending RPC""" """Check if a peer is still connected and has open data channels before sending RPC"""
if not multiplayer.has_multiplayer_peer(): if not multiplayer.has_multiplayer_peer():
@@ -5291,6 +5512,10 @@ func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float
_set_animation("STAFF") _set_animation("STAFF")
"arrow": "arrow":
_set_animation("BOW") _set_animation("BOW")
"axe":
_set_animation("AXE")
"punch":
_set_animation("PUNCH")
_: _:
_set_animation("SWORD") _set_animation("SWORD")
@@ -5316,6 +5541,19 @@ func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float
get_parent().add_child(arrow_projectile) get_parent().add_child(arrow_projectile)
arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage) arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage)
print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)") print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)")
elif projectile_type == "axe" and attack_axe_swing_scene:
var axe_swing = attack_axe_swing_scene.instantiate()
get_parent().add_child(axe_swing)
var axe_item = character_stats.equipment.get("mainhand", null) if character_stats else null
axe_swing.setup(attack_dir, self, -1.0, axe_item)
axe_swing.global_position = global_position
print(name, " performed synced axe swing!")
elif projectile_type == "punch" and attack_punch_scene:
var punch = attack_punch_scene.instantiate()
get_parent().add_child(punch)
punch.setup(attack_dir, self, 3.0)
punch.global_position = global_position + attack_dir * 12.0
print(name, " performed synced punch!")
elif (projectile_type == "sword" or projectile_type == "") and sword_projectile_scene: elif (projectile_type == "sword" or projectile_type == "") and 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)
@@ -6126,7 +6364,9 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
# Apply the reduced damage using take_damage (which handles health modification and signals) # Apply the reduced damage using take_damage (which handles health modification and signals)
var _old_hp = character_stats.hp var _old_hp = character_stats.hp
character_stats.modify_health(-actual_damage) character_stats.modify_health(-actual_damage)
if character_stats.hp <= 0: # Check if dead (use epsilon to handle floating point precision)
if character_stats.hp <= 0.001:
character_stats.hp = 0.0 # Ensure exactly 0
character_stats.no_health.emit() character_stats.no_health.emit()
character_stats.character_changed.emit(character_stats) character_stats.character_changed.emit(character_stats)
print(name, " took ", actual_damage, " damage (", amount, " base - ", character_stats.defense, " DEF = ", actual_damage, ")! Health: ", character_stats.hp, "/", character_stats.maxhp) print(name, " took ", actual_damage, " damage (", amount, " base - ", character_stats.defense, " DEF = ", actual_damage, ")! Health: ", character_stats.hp, "/", character_stats.maxhp)
@@ -6187,12 +6427,13 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
_rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position]) _rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position])
# Check if dead - but wait for damage animation to play first # Check if dead - but wait for damage animation to play first
# Use small epsilon to handle floating point precision issues (HP might be 0.0000001 instead of exactly 0.0)
var health = character_stats.hp if character_stats else current_health var health = character_stats.hp if character_stats else current_health
if health <= 0: if health <= 0.001: # Use epsilon to catch values very close to 0
if character_stats: if character_stats:
character_stats.hp = 0 # Clamp to 0 character_stats.hp = 0.0 # Clamp to exactly 0
else: else:
current_health = 0 # Clamp to 0 current_health = 0.0 # Clamp to exactly 0
is_dead = true # Set flag immediately to prevent more damage is_dead = true # Set flag immediately to prevent more damage
# Wait a bit for damage animation and knockback to show # Wait a bit for damage animation and knockback to show
await get_tree().create_timer(0.3).timeout await get_tree().create_timer(0.3).timeout
@@ -6407,7 +6648,8 @@ func _spawn_landing_stand_up():
status_anim.play("idle") status_anim.play("idle")
# STAND's nextAnimation -> IDLE, so we're already IDLE or about to be # STAND's nextAnimation -> IDLE, so we're already IDLE or about to be
spawn_landing = false spawn_landing = false
if cone_light: # Only show cone light for local player (don't show other players' cone lights)
if is_local_player and cone_light:
cone_light.visible = true cone_light.visible = true
if point_light: if point_light:
point_light.visible = true point_light.visible = true
@@ -6428,9 +6670,11 @@ func _respawn():
print(name, " respawning!") print(name, " respawning!")
was_revived = false was_revived = false
# Get game_world reference (used multiple times in this function)
var game_world = get_tree().get_first_node_in_group("game_world")
# Hide GAME OVER screen and fade in game graphics when player respawns (only on authority) # Hide GAME OVER screen and fade in game graphics when player respawns (only on authority)
if is_multiplayer_authority(): if is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_hide_game_over"): if game_world and game_world.has_method("_hide_game_over"):
game_world._hide_game_over() game_world._hide_game_over()
@@ -6469,7 +6713,6 @@ func _respawn():
# Get respawn position - use spawn room (start room) for respawning # Get respawn position - use spawn room (start room) for respawning
var new_respawn_pos = respawn_point var new_respawn_pos = respawn_point
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world: if not game_world:
push_error(name, " respawn: Could not find game_world!") push_error(name, " respawn: Could not find game_world!")
@@ -6621,6 +6864,10 @@ func _revive_from_player(hp_amount: int):
_set_animation("IDLE") _set_animation("IDLE")
# Same healing effect as Tome of Healing (green frames, pulse, +X HP) # Same healing effect as Tome of Healing (green frames, pulse, +X HP)
_spawn_heal_effect_and_text(self, hp_amount, false, false) _spawn_heal_effect_and_text(self, hp_amount, false, false)
# CRITICAL: Unregister from dead_players dictionary so game knows we're alive
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_unregister_player_died"):
game_world._unregister_player_died(self)
# Clear concussion on all clients (authority already did above; broadcast for others) # Clear concussion on all clients (authority already did above; broadcast for others)
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():
_rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) _rpc_to_ready_peers("_sync_revived_clear_concussion", [name])
@@ -6661,6 +6908,10 @@ func _revive_from_heal(hp_amount: int):
status_anim.play("idle") status_anim.play("idle")
_set_animation("IDLE") _set_animation("IDLE")
_spawn_heal_effect_and_text(self, hp_amount, false, false) _spawn_heal_effect_and_text(self, hp_amount, false, false)
# CRITICAL: Unregister from dead_players dictionary so game knows we're alive
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_unregister_player_died"):
game_world._unregister_player_died(self)
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():
_rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) _rpc_to_ready_peers("_sync_revived_clear_concussion", [name])
@@ -6745,16 +6996,16 @@ func add_coins(amount: int):
var the_peer_id = get_multiplayer_authority() var the_peer_id = get_multiplayer_authority()
# Only sync if this is a client player (not server's own player) # Only sync if this is a client player (not server's own player)
if the_peer_id != 0 and the_peer_id != multiplayer.get_unique_id(): if the_peer_id != 0 and the_peer_id != multiplayer.get_unique_id():
print(name, " syncing stats to client peer_id=", the_peer_id, " kills=", character_stats.kills, " coins=", character_stats.coin) print(name, " syncing stats to client peer_id=", the_peer_id, " kills=", character_stats.kills, " coins=", character_stats.coin, " xp=", character_stats.xp)
_sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin) _sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin, character_stats.xp)
else: else:
coins += amount coins += amount
print(name, " picked up ", amount, " coin(s)! Total coins: ", coins) print(name, " picked up ", amount, " coin(s)! Total coins: ", coins)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_stats_update(kills_count: int, coins_count: int): func _sync_stats_update(kills_count: int, coins_count: int, xp_amount: float = -1.0):
# Client receives stats update from server (for kills and coins) # Client receives stats update from server (for kills, coins, and XP)
# Update local stats to match server # Update local stats to match server
# Only process on client (not on server where the update originated) # Only process on client (not on server where the update originated)
if multiplayer.is_server(): if multiplayer.is_server():
@@ -6763,13 +7014,21 @@ func _sync_stats_update(kills_count: int, coins_count: int):
if character_stats: if character_stats:
character_stats.kills = kills_count character_stats.kills = kills_count
character_stats.coin = coins_count character_stats.coin = coins_count
print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count) if xp_amount >= 0.0: # Only update XP if provided (backwards compatible)
# Calculate the difference and add it (to trigger level up if needed)
var xp_diff = xp_amount - character_stats.xp
if xp_diff > 0.0:
character_stats.add_xp(xp_diff)
else:
character_stats.xp = xp_amount
var xp_display = str(xp_amount) if xp_amount >= 0.0 else "unchanged"
print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count, " xp=", xp_display)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_race_and_stats(race: String, base_stats: Dictionary): func _sync_race_and_stats(race: String, base_stats: Dictionary):
# Client receives race and base stats from authority player # Client receives race and base stats from authority player
# Accept initial sync (when race is empty), but reject changes if we're authority # Accept initial sync (when race is empty), but reject changes if we're authority
print(name, " _sync_race_and_stats received: race=", race, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null") print("Player ", name, " RECEIVED _sync_race_and_stats: race='", race, "' (peer_id=", peer_id, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null", ")")
# CRITICAL: If we're the authority for this player, we should NOT process race syncs # CRITICAL: If we're the authority for this player, we should NOT process race syncs
# The authority player manages its own appearance and only syncs to others # The authority player manages its own appearance and only syncs to others
@@ -6783,6 +7042,7 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
if character_stats: if character_stats:
character_stats.race = race character_stats.race = race
character_stats.baseStats = base_stats character_stats.baseStats = base_stats
print("Player ", name, " APPLIED _sync_race_and_stats: this node now has race='", race, "' (server/other peer's view of this player)")
# For remote players, we don't re-initialize appearance here # For remote players, we don't re-initialize appearance here
# Instead, we wait for _sync_appearance RPC which contains the full appearance data # Instead, we wait for _sync_appearance RPC which contains the full appearance data
@@ -6819,7 +7079,7 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
"Dwarf": "Dwarf":
character_stats.setEars(0) character_stats.setEars(0)
# Give Dwarf starting bombs to remote players ONLY when offhand is null (initial sync) # Give Dwarf starting bombs + debug weapons to remote players ONLY when offhand is null (initial sync)
# Never overwrite existing equipment (e.g. shield, tome) - preserves loadout across level transitions # Never overwrite existing equipment (e.g. shield, tome) - preserves loadout across level transitions
if not is_multiplayer_authority(): if not is_multiplayer_authority():
if character_stats.equipment["offhand"] == null: if character_stats.equipment["offhand"] == null:
@@ -6827,12 +7087,21 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
if starting_bomb: if starting_bomb:
starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start
character_stats.equipment["offhand"] = starting_bomb character_stats.equipment["offhand"] = starting_bomb
var debug_axe = ItemDatabase.create_item("axe")
if debug_axe:
character_stats.add_item(debug_axe)
var debug_dagger = ItemDatabase.create_item("knife")
if debug_dagger:
character_stats.add_item(debug_dagger)
var debug_sword = ItemDatabase.create_item("short_sword")
if debug_sword:
character_stats.add_item(debug_sword)
_apply_appearance_to_sprites() _apply_appearance_to_sprites()
print("Dwarf player ", name, " (remote) received 5 bombs via race sync") print("Dwarf player ", name, " (remote) received 5 bombs and debug axe/dagger/sword via race sync")
"Human": "Human":
character_stats.setEars(0) character_stats.setEars(0)
# Give Human (Wizard) starting tome and hat to remote players ONLY when slots are null (initial sync) # Give Human (Wizard) starting tomes and hat to remote players ONLY when slots are null (initial sync)
# Never overwrite existing equipment - preserves loadout across level transitions # Never overwrite existing equipment - preserves loadout across level transitions
if not is_multiplayer_authority(): if not is_multiplayer_authority():
var offhand_empty = character_stats.equipment["offhand"] == null var offhand_empty = character_stats.equipment["offhand"] == null
@@ -6841,11 +7110,17 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
var starting_tome = ItemDatabase.create_item("tome_of_flames") var starting_tome = ItemDatabase.create_item("tome_of_flames")
if starting_tome: if starting_tome:
character_stats.equipment["offhand"] = starting_tome character_stats.equipment["offhand"] = starting_tome
var tome_healing = ItemDatabase.create_item("tome_of_healing")
if tome_healing:
character_stats.add_item(tome_healing)
var tome_frostspike = ItemDatabase.create_item("tome_of_frostspike")
if tome_frostspike:
character_stats.add_item(tome_frostspike)
var starting_hat = ItemDatabase.create_item("hat") var starting_hat = ItemDatabase.create_item("hat")
if starting_hat: if starting_hat:
character_stats.equipment["headgear"] = starting_hat character_stats.equipment["headgear"] = starting_hat
_apply_appearance_to_sprites() _apply_appearance_to_sprites()
print("Human player ", name, " (remote) received Tome of Flames and Hat via race sync") print("Human player ", name, " (remote) received Tome of Flames, Tome of Healing, Tome of Frostspike, and Hat via race sync")
_: _:
character_stats.setEars(0) character_stats.setEars(0)
@@ -7013,7 +7288,6 @@ func _sync_keys(new_key_count: int):
func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false): func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false):
# Show damage number (red, using dmg_numbers.png font) above player # Show damage number (red, using dmg_numbers.png font) above player
# Show even if amount is 0 for MISS/DODGED/BLOCKED # Show even if amount is 0 for MISS/DODGED/BLOCKED
var damage_number_scene = preload("res://scenes/damage_number.tscn") var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene: if not damage_number_scene:
return return
@@ -7077,6 +7351,24 @@ func _show_revive_cost_number(amount: int):
get_tree().current_scene.add_child(damage_label) get_tree().current_scene.add_child(damage_label)
damage_label.global_position = global_position + Vector2(0, -16) damage_label.global_position = global_position + Vector2(0, -16)
func _show_not_enough_mana_text():
"""Show 'NOT ENOUGH MANA' in damage_number font above player (local player only)."""
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
var lbl = damage_number_scene.instantiate()
if not lbl:
return
lbl.label = "NOT ENOUGH MANA"
lbl.color = Color(1.0, 0.5, 0.2) # Orange/red color
lbl.z_index = 5
lbl.direction = Vector2(0, -1)
var game_world = get_tree().get_first_node_in_group("game_world")
var parent = game_world.get_node_or_null("Entities") if game_world else get_tree().current_scene
if parent:
parent.add_child(lbl)
lbl.global_position = global_position + Vector2(0, -20)
func show_floating_status(text: String, col: Color = Color.WHITE) -> void: func show_floating_status(text: String, col: Color = Color.WHITE) -> void:
"""Show a damage-number-style floating text above player (e.g. 'Encumbered!').""" """Show a damage-number-style floating text above player (e.g. 'Encumbered!')."""
var damage_number_scene = preload("res://scenes/damage_number.tscn") var damage_number_scene = preload("res://scenes/damage_number.tscn")
@@ -7297,6 +7589,13 @@ func _on_exit_found():
if not is_multiplayer_authority(): if not is_multiplayer_authority():
return # Only authority triggers return # Only authority triggers
# Only show notification once per level
if has_seen_exit_this_level:
return
# Mark as seen for this level
has_seen_exit_this_level = true
# Show exclamation mark # Show exclamation mark
_show_alert_indicator() _show_alert_indicator()

View File

@@ -83,6 +83,12 @@ func spawn_player(peer_id: int, local_index: int):
player.position = spawn_pos player.position = spawn_pos
# Set multiplayer authority BEFORE add_child so _ready() sees correct authority.
# Otherwise joiner's player runs _ready() with default authority (e.g. server) and skips _setup_player_appearance(),
# so joiner Wizard never gets tome_of_healing / tome_of_frostspike.
if multiplayer.has_multiplayer_peer():
player.set_multiplayer_authority(peer_id)
# Add to YSort node for automatic Y-sorting # Add to YSort node for automatic Y-sorting
var ysort = get_parent().get_node_or_null("Entities") var ysort = get_parent().get_node_or_null("Entities")
if ysort: if ysort:
@@ -91,10 +97,6 @@ func spawn_player(peer_id: int, local_index: int):
# Fallback to parent if YSort doesn't exist # Fallback to parent if YSort doesn't exist
get_parent().add_child(player) get_parent().add_child(player)
# Set multiplayer authority AFTER adding to scene
if multiplayer.has_multiplayer_peer():
player.set_multiplayer_authority(peer_id)
players[unique_id] = player players[unique_id] = player
func despawn_players_for_peer(peer_id: int): func despawn_players_for_peer(peer_id: int):

View File

@@ -49,6 +49,16 @@ func _on_player_entered_room(player: Node):
LogManager.log("RoomTrigger: This trigger is for room (" + str(room.x) + ", " + str(room.y) + ", " + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON) LogManager.log("RoomTrigger: This trigger is for room (" + str(room.x) + ", " + str(room.y) + ", " + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON)
LogManager.log("RoomTrigger: Found " + str(doors_in_room.size()) + " doors in this room", LogManager.CATEGORY_DUNGEON) LogManager.log("RoomTrigger: Found " + str(doors_in_room.size()) + " doors in this room", LogManager.CATEGORY_DUNGEON)
# Check if this is the exit room (has EXIT modifier) - trigger exit found notification
if room.has("modifiers") and room.modifiers is Array:
for modifier in room.modifiers:
if modifier is Dictionary and modifier.has("type") and modifier.type == "EXIT":
# This is the exit room - notify player to show alert and play sound
if player and is_instance_valid(player) and player.has_method("_on_exit_found"):
player._on_exit_found()
LogManager.log("RoomTrigger: Exit room entered! Player " + str(player.name) + " found the exit!", LogManager.CATEGORY_DUNGEON)
break
# Mark room as entered and update debug label # Mark room as entered and update debug label
room_entered = true room_entered = true
_update_debug_label() _update_debug_label()

View File

@@ -0,0 +1,96 @@
extends Node2D
# Race select: hover/click the race buttons to reveal and select. Keyboard (move_left/right, attack) still works.
# Dwarf preselected. Emits race_selected(race_name) when confirmed.
signal race_selected(race_name: String)
const RACES: Array[String] = ["Dwarf", "Elf", "Human"] # Wizard = Human
var selected_index: int = 0 # Dwarf preselected
var hovered_index: int = -1 # -1 = no hover
var _selection_confirmed: bool = false # One-shot: only first click counts (avoids overlapping buttons overwriting)
@onready var dwarf_node: Node2D = $Dwarf
@onready var elf_node: Node2D = $Elf
@onready var wizard_node: Node2D = $Wizard
@onready var button_dwarf: Button = $Control/ButtonDwarf
@onready var button_elf: Button = $Control/ButtonElf
@onready var button_wizard: Button = $Control/ButtonWizard
func _ready() -> void:
var vp = get_viewport()
if vp:
position = vp.get_visible_rect().size / 2.0
_update_display()
_connect_buttons()
func _connect_buttons() -> void:
var buttons := [button_dwarf, button_elf, button_wizard]
for i in buttons.size():
var btn: Button = buttons[i]
if not btn:
continue
# Hover: show this race
btn.mouse_entered.connect(_on_race_button_mouse_entered.bind(i))
btn.mouse_exited.connect(_on_race_button_mouse_exited)
# Click: select this race and confirm
btn.pressed.connect(_on_race_button_pressed.bind(i))
func _on_race_button_mouse_entered(race_index: int) -> void:
hovered_index = race_index
_update_display()
func _on_race_button_mouse_exited() -> void:
hovered_index = -1
_update_display()
func _on_race_button_pressed(race_index: int) -> void:
# Use the clicked button's index directly (don't rely on selected_index - another button may have fired too)
selected_index = race_index
_confirm_selection_with_index(race_index)
func _input(event: InputEvent) -> void:
var vp = get_viewport()
if not vp:
return
if event.is_action_pressed("move_left"):
selected_index = (selected_index - 1 + RACES.size()) % RACES.size()
hovered_index = -1
_update_display()
vp.set_input_as_handled()
if event.is_action_pressed("move_right"):
selected_index = (selected_index + 1) % RACES.size()
hovered_index = -1
_update_display()
vp.set_input_as_handled()
if event.is_action_pressed("attack"):
# Attack can be mouse1 or keyboard: use hovered race if mouse is over a button, else keyboard selection
var index_to_confirm := hovered_index if hovered_index >= 0 else selected_index
_confirm_selection_with_index(index_to_confirm)
vp.set_input_as_handled()
func _update_display() -> void:
var show_index: int = hovered_index if hovered_index >= 0 else selected_index
if dwarf_node:
dwarf_node.visible = (show_index == 0)
if elf_node:
elf_node.visible = (show_index == 1)
if wizard_node:
wizard_node.visible = (show_index == 2)
func _confirm_selection_with_index(clicked_index: int) -> void:
# Only process first confirmation (overlapping buttons can fire multiple signals; first click wins)
if _selection_confirmed:
return
_selection_confirmed = true
# Use the index from the button that was actually clicked (not selected_index which could be overwritten)
if clicked_index < 0 or clicked_index >= RACES.size():
clicked_index = selected_index
var race := RACES[clicked_index]
var gs = get_node_or_null("/root/GameState")
if gs and "selected_race" in gs:
gs.selected_race = race
print("SelectClass: joiner clicked index ", clicked_index, " -> race '", race, "', GameState.selected_race = '", gs.selected_race, "'")
else:
print("SelectClass: joiner clicked index ", clicked_index, " -> race '", race, "' but GameState not found")
race_selected.emit(race)

View File

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

View File

@@ -127,22 +127,12 @@ func _on_body_entered(body):
# Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing)
hit_targets[body] = true hit_targets[body] = true
# Deal damage to players - call RPC to let victim apply damage on their client # Ignore other players - projectile passes through (no friendly fire)
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"):
$SfxImpact.play() return
var attacker_pos = player_owner.global_position if player_owner else global_position
var player_peer_id = body.get_multiplayer_authority()
if player_peer_id != 0:
if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id():
body.rpc_take_damage(damage, attacker_pos)
else:
body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos)
else:
body.rpc_take_damage.rpc(damage, attacker_pos)
print("Staff projectile hit player: ", body.name, " for ", damage, " damage!")
# Deal damage to enemies - only authority (creator) deals damage # Deal damage to enemies - only authority (creator) deals damage
elif 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"):
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 is_crit = get_meta("is_crit") if has_meta("is_crit") else false var is_crit = get_meta("is_crit") if has_meta("is_crit") else false

View File

@@ -31,10 +31,6 @@ func _on_body_entered(body: Node2D):
if body and body.is_in_group("player") and not body.is_dead: if body and body.is_in_group("player") and not body.is_dead:
print("Stairs: Player entered stairs! Player: ", body.name) print("Stairs: Player entered stairs! Player: ", body.name)
# Notify the player to show alert and play sound
if body and is_instance_valid(body) and body.has_method("_on_exit_found"):
body._on_exit_found()
# Play stairs sound effect # Play stairs sound effect
if sfx_stairs and sfx_stairs.stream: if sfx_stairs and sfx_stairs.stream:
sfx_stairs.play() sfx_stairs.play()

View File

@@ -81,28 +81,26 @@ func _on_body_entered(body):
# Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing)
hit_targets[body] = true hit_targets[body] = true
# Deal damage to players - call RPC to let victim apply damage on their client # Friendly fire: only skip when owner is also a player. Enemies can hit players.
# Pass the attacker's position (not projectile position) for accurate direction
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"):
$SfxImpact.play() if player_owner and player_owner.is_in_group("player"):
return # Owner is player → pass through, no damage
# Owner is enemy → deal damage to player (same pattern as enemy_base)
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 player_peer_id = body.get_multiplayer_authority() var player_peer_id = body.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 multiplayer.get_unique_id() == player_peer_id:
# rpc_id() might not execute locally when called to same peer body.rpc_take_damage(damage, attacker_pos, false, false)
if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id():
# Call directly on the same peer
body.rpc_take_damage(damage, attacker_pos)
else: else:
# Send RPC to remote peer body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos, false, false)
body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos)
else: else:
# Fallback: broadcast if we can't get peer_id body.rpc_take_damage.rpc(damage, attacker_pos, false, false)
body.rpc_take_damage.rpc(damage, attacker_pos) if has_node("SfxImpact"):
print("Sword projectile hit player: ", body.name, " for ", damage, " damage!") $SfxImpact.play()
return
# Deal damage to enemies - only authority (creator) deals damage # Deal damage to enemies - only authority (creator) deals damage
elif 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"):
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 is_crit = get_meta("is_crit") if has_meta("is_crit") else false var is_crit = get_meta("is_crit") if has_meta("is_crit") else false
@@ -147,6 +145,16 @@ func _on_body_entered(body):
else: else:
body.rpc_take_damage.rpc(damage, attacker_pos, is_crit) body.rpc_take_damage.rpc(damage, attacker_pos, is_crit)
# Slash hit effect (sync so all clients see it)
var hit_pos = body.global_position
if game_world and game_world.has_method("_sync_slash_hit_effect"):
if multiplayer.is_server():
game_world._sync_slash_hit_effect(hit_pos.x, hit_pos.y)
if game_world.has_method("_rpc_to_ready_peers"):
game_world._rpc_to_ready_peers("_sync_slash_hit_effect", [hit_pos.x, hit_pos.y])
else:
game_world._request_slash_hit_effect.rpc_id(1, hit_pos.x, hit_pos.y)
# Debug print - handle null player_owner safely # Debug print - handle null player_owner safely
var owner_name: String = "none" var owner_name: String = "none"
var is_authority: bool = false var is_authority: bool = false

View File

@@ -64,13 +64,24 @@ func _on_body_entered(body):
# Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing)
hit_targets[body] = true hit_targets[body] = true
# Deal damage to players # Friendly fire: only skip when owner is also a player. Enemies can hit players.
if body.is_in_group("player") and body.has_method("take_damage"): if body.is_in_group("player") and body.has_method("take_damage"):
body.take_damage(damage, global_position) if player_owner and player_owner.is_in_group("player"):
print("Sword hit player: ", body.name, " for ", damage, " damage!") return # Owner is player → pass through, no damage
# Owner is enemy → deal damage to player
var attacker_pos = player_owner.global_position if player_owner and is_instance_valid(player_owner) else global_position
var player_peer_id = body.get_multiplayer_authority()
if player_peer_id != 0:
if multiplayer.get_unique_id() == player_peer_id:
body.rpc_take_damage(damage, attacker_pos, false, false)
else:
body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos, false, false)
else:
body.rpc_take_damage.rpc(damage, attacker_pos, false, false)
return
# Deal damage to boxes or other damageable objects # Deal damage to boxes or other damageable objects
elif "health" in body: if "health" in body:
# Boxes have health property # Boxes have health property
body.health -= damage body.health -= damage
if body.health <= 0 and body.has_method("_break_into_pieces"): if body.health <= 0 and body.has_method("_break_into_pieces"):

View File

@@ -264,6 +264,38 @@ func _complete_disarm() -> void:
# Change trap visual to show it's disarmed (optional - could fade out or change color) # 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) sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
# Grant EXP to all players for disarming trap (only on server)
# CRITICAL: Only server should grant EXP to avoid duplicates
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var trap_exp_reward = 8.0 # EXP reward for disarming a trap
var all_players = get_tree().get_nodes_in_group("player")
var valid_players = []
for player in all_players:
if is_instance_valid(player) and player.character_stats:
valid_players.append(player)
if valid_players.size() > 0:
# Split EXP evenly among all players
var exp_per_player = trap_exp_reward / valid_players.size()
for player in valid_players:
player.character_stats.add_xp(exp_per_player)
LogManager.log("Trap disarmed: granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(trap_exp_reward) + " total)", LogManager.CATEGORY_ENEMY)
# Sync EXP to client if this player belongs to a client
var player_peer_id = player.get_multiplayer_authority()
if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"):
var coins = player.character_stats.coin if "coin" in player.character_stats else 0
var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0
player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
# Show floating EXP text at trap position and sync to all clients
# Show locally first
_show_exp_number(exp_per_player, global_position)
# Sync to all clients via game_world
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position)
# Sync disarm to all clients (including host when joiner disarms) # Sync disarm to all clients (including host when joiner disarms)
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
@@ -291,6 +323,38 @@ func _sync_trap_disarmed() -> void:
if activation_area: if activation_area:
activation_area.monitoring = false activation_area.monitoring = false
func _show_exp_number(amount: float, exp_pos: Vector2):
# Show EXP number (green, using dmg_numbers.png font) at position
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
var exp_label = damage_number_scene.instantiate()
if not exp_label:
return
# Set text and color for EXP (green)
exp_label.label = "+" + str(int(amount)) + " EXP"
exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
exp_label.z_index = 5
# Direction is straight up
exp_label.direction = Vector2(0, -1)
# Position at the specified location
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
entities_node.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
func _show_floating_text(text: String, color: Color) -> void: func _show_floating_text(text: String, color: Color) -> void:
var floating_text_scene = preload("res://scenes/floating_text.tscn") var floating_text_scene = preload("res://scenes/floating_text.tscn")
if floating_text_scene: if floating_text_scene: