diff --git a/Assets/Characters/warrior/super_attack.png b/Assets/Characters/warrior/super_attack.png
new file mode 100644
index 0000000..5c7b851
Binary files /dev/null and b/Assets/Characters/warrior/super_attack.png differ
diff --git a/Assets/Characters/warrior/super_attack.png.import b/Assets/Characters/warrior/super_attack.png.import
new file mode 100644
index 0000000..92b29f1
--- /dev/null
+++ b/Assets/Characters/warrior/super_attack.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/super_attack.png-633190e19ed593ab2a2ddf9b68781b2d.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/Characters/warrior/super_attack.png"
+dest_files=[ "res://.import/super_attack.png-633190e19ed593ab2a2ddf9b68781b2d.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/arrow_multi.png b/Assets/SkillAnimation/arrow_multi.png
new file mode 100644
index 0000000..a6d4d80
Binary files /dev/null and b/Assets/SkillAnimation/arrow_multi.png differ
diff --git a/Assets/SkillAnimation/arrow_multi.png.import b/Assets/SkillAnimation/arrow_multi.png.import
new file mode 100644
index 0000000..6c78789
--- /dev/null
+++ b/Assets/SkillAnimation/arrow_multi.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/arrow_multi.png-e566f9557bb7c8a12ed527f7bd9cf3b9.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/arrow_multi.png"
+dest_files=[ "res://.import/arrow_multi.png-e566f9557bb7c8a12ed527f7bd9cf3b9.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/arrow_ricochet.png b/Assets/SkillAnimation/arrow_ricochet.png
new file mode 100644
index 0000000..651237f
Binary files /dev/null and b/Assets/SkillAnimation/arrow_ricochet.png differ
diff --git a/Assets/SkillAnimation/arrow_ricochet.png.import b/Assets/SkillAnimation/arrow_ricochet.png.import
new file mode 100644
index 0000000..a22058a
--- /dev/null
+++ b/Assets/SkillAnimation/arrow_ricochet.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/arrow_ricochet.png-3ddf9c10400670aafbba52b76ec07756.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/arrow_ricochet.png"
+dest_files=[ "res://.import/arrow_ricochet.png-3ddf9c10400670aafbba52b76ec07756.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/digit_arrow.png b/Assets/SkillAnimation/digit_arrow.png
new file mode 100644
index 0000000..9c8c3dc
Binary files /dev/null and b/Assets/SkillAnimation/digit_arrow.png differ
diff --git a/Assets/SkillAnimation/digit_arrow.png.import b/Assets/SkillAnimation/digit_arrow.png.import
new file mode 100644
index 0000000..b450a95
--- /dev/null
+++ b/Assets/SkillAnimation/digit_arrow.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/digit_arrow.png-ab848d6fe8f3279f18f1517bb8377629.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/digit_arrow.png"
+dest_files=[ "res://.import/digit_arrow.png-ab848d6fe8f3279f18f1517bb8377629.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/fireball_multi.png b/Assets/SkillAnimation/fireball_multi.png
new file mode 100644
index 0000000..d88ea49
Binary files /dev/null and b/Assets/SkillAnimation/fireball_multi.png differ
diff --git a/Assets/SkillAnimation/fireball_multi.png.import b/Assets/SkillAnimation/fireball_multi.png.import
new file mode 100644
index 0000000..b8fdfad
--- /dev/null
+++ b/Assets/SkillAnimation/fireball_multi.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/fireball_multi.png-e38cb0ce02f3fbe5dea14a9f58cb6f01.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/fireball_multi.png"
+dest_files=[ "res://.import/fireball_multi.png-e38cb0ce02f3fbe5dea14a9f58cb6f01.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/fly_sword-1.png b/Assets/SkillAnimation/fly_sword-1.png
new file mode 100644
index 0000000..bdd193f
Binary files /dev/null and b/Assets/SkillAnimation/fly_sword-1.png differ
diff --git a/Assets/SkillAnimation/fly_sword-1.png.import b/Assets/SkillAnimation/fly_sword-1.png.import
new file mode 100644
index 0000000..99086ee
--- /dev/null
+++ b/Assets/SkillAnimation/fly_sword-1.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/fly_sword-1.png-a7442a514264b0f0f1d43aaaa6e12a4c.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/fly_sword-1.png"
+dest_files=[ "res://.import/fly_sword-1.png-a7442a514264b0f0f1d43aaaa6e12a4c.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/fly_sword-2.png b/Assets/SkillAnimation/fly_sword-2.png
new file mode 100644
index 0000000..ee5d59b
Binary files /dev/null and b/Assets/SkillAnimation/fly_sword-2.png differ
diff --git a/Assets/SkillAnimation/fly_sword-2.png.import b/Assets/SkillAnimation/fly_sword-2.png.import
new file mode 100644
index 0000000..8fa6a80
--- /dev/null
+++ b/Assets/SkillAnimation/fly_sword-2.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/fly_sword-2.png-ba5560b5f2b4f5ceab352d4b9dd136b5.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/fly_sword-2.png"
+dest_files=[ "res://.import/fly_sword-2.png-ba5560b5f2b4f5ceab352d4b9dd136b5.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/fly_sword.png b/Assets/SkillAnimation/fly_sword.png
new file mode 100644
index 0000000..c07efcf
Binary files /dev/null and b/Assets/SkillAnimation/fly_sword.png differ
diff --git a/Assets/SkillAnimation/fly_sword.png.import b/Assets/SkillAnimation/fly_sword.png.import
new file mode 100644
index 0000000..b0a8ec9
--- /dev/null
+++ b/Assets/SkillAnimation/fly_sword.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/fly_sword.png-1f0c4760f72d2f7f95268d8f3775b3da.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/fly_sword.png"
+dest_files=[ "res://.import/fly_sword.png-1f0c4760f72d2f7f95268d8f3775b3da.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/ice_spear-1.png b/Assets/SkillAnimation/ice_spear-1.png
new file mode 100644
index 0000000..17f5b68
Binary files /dev/null and b/Assets/SkillAnimation/ice_spear-1.png differ
diff --git a/Assets/SkillAnimation/ice_spear-1.png.import b/Assets/SkillAnimation/ice_spear-1.png.import
new file mode 100644
index 0000000..490f2c7
--- /dev/null
+++ b/Assets/SkillAnimation/ice_spear-1.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/ice_spear-1.png-f15385af1dff55dac62c53c93bee3f68.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/ice_spear-1.png"
+dest_files=[ "res://.import/ice_spear-1.png-f15385af1dff55dac62c53c93bee3f68.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/ice_spear-2.png b/Assets/SkillAnimation/ice_spear-2.png
new file mode 100644
index 0000000..0b90730
Binary files /dev/null and b/Assets/SkillAnimation/ice_spear-2.png differ
diff --git a/Assets/SkillAnimation/ice_spear-2.png.import b/Assets/SkillAnimation/ice_spear-2.png.import
new file mode 100644
index 0000000..3c1483a
--- /dev/null
+++ b/Assets/SkillAnimation/ice_spear-2.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/ice_spear-2.png-2ac56cb1417d84f4934144826ebd5e68.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/ice_spear-2.png"
+dest_files=[ "res://.import/ice_spear-2.png-2ac56cb1417d84f4934144826ebd5e68.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/ice_spear-3.png b/Assets/SkillAnimation/ice_spear-3.png
new file mode 100644
index 0000000..d41d0ce
Binary files /dev/null and b/Assets/SkillAnimation/ice_spear-3.png differ
diff --git a/Assets/SkillAnimation/ice_spear-3.png.import b/Assets/SkillAnimation/ice_spear-3.png.import
new file mode 100644
index 0000000..a7748b3
--- /dev/null
+++ b/Assets/SkillAnimation/ice_spear-3.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/ice_spear-3.png-f05297fe0b1015db0c423258b10854d8.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/ice_spear-3.png"
+dest_files=[ "res://.import/ice_spear-3.png-f05297fe0b1015db0c423258b10854d8.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/ice_spear-4.png b/Assets/SkillAnimation/ice_spear-4.png
new file mode 100644
index 0000000..7f75f55
Binary files /dev/null and b/Assets/SkillAnimation/ice_spear-4.png differ
diff --git a/Assets/SkillAnimation/ice_spear-4.png.import b/Assets/SkillAnimation/ice_spear-4.png.import
new file mode 100644
index 0000000..eda60be
--- /dev/null
+++ b/Assets/SkillAnimation/ice_spear-4.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/ice_spear-4.png-1e5e6059b5a2f182cc61a26afe3156d6.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/ice_spear-4.png"
+dest_files=[ "res://.import/ice_spear-4.png-1e5e6059b5a2f182cc61a26afe3156d6.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/ice_spear-hit.png b/Assets/SkillAnimation/ice_spear-hit.png
new file mode 100644
index 0000000..9dcd042
Binary files /dev/null and b/Assets/SkillAnimation/ice_spear-hit.png differ
diff --git a/Assets/SkillAnimation/ice_spear-hit.png.import b/Assets/SkillAnimation/ice_spear-hit.png.import
new file mode 100644
index 0000000..96b3c99
--- /dev/null
+++ b/Assets/SkillAnimation/ice_spear-hit.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/ice_spear-hit.png-fa4c9ba5fcc39a568b39b3544fe0b06d.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/ice_spear-hit.png"
+dest_files=[ "res://.import/ice_spear-hit.png-fa4c9ba5fcc39a568b39b3544fe0b06d.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/knight_explose.png b/Assets/SkillAnimation/knight_explose.png
new file mode 100644
index 0000000..d5301a3
Binary files /dev/null and b/Assets/SkillAnimation/knight_explose.png differ
diff --git a/Assets/SkillAnimation/knight_explose.png.import b/Assets/SkillAnimation/knight_explose.png.import
new file mode 100644
index 0000000..7e45ba3
--- /dev/null
+++ b/Assets/SkillAnimation/knight_explose.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/knight_explose.png-e78dccd81f3064883d18ab167deb7824.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/knight_explose.png"
+dest_files=[ "res://.import/knight_explose.png-e78dccd81f3064883d18ab167deb7824.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/magic_slash-2.png b/Assets/SkillAnimation/magic_slash-2.png
new file mode 100644
index 0000000..fae9e19
Binary files /dev/null and b/Assets/SkillAnimation/magic_slash-2.png differ
diff --git a/Assets/SkillAnimation/magic_slash-2.png.import b/Assets/SkillAnimation/magic_slash-2.png.import
new file mode 100644
index 0000000..e104d9e
--- /dev/null
+++ b/Assets/SkillAnimation/magic_slash-2.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/magic_slash-2.png-9de23a3207cfb975fd9d216b38cd9fd2.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/magic_slash-2.png"
+dest_files=[ "res://.import/magic_slash-2.png-9de23a3207cfb975fd9d216b38cd9fd2.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/roundhouse-slash.png b/Assets/SkillAnimation/roundhouse-slash.png
new file mode 100644
index 0000000..ecb4ad8
Binary files /dev/null and b/Assets/SkillAnimation/roundhouse-slash.png differ
diff --git a/Assets/SkillAnimation/roundhouse-slash.png.import b/Assets/SkillAnimation/roundhouse-slash.png.import
new file mode 100644
index 0000000..c45204f
--- /dev/null
+++ b/Assets/SkillAnimation/roundhouse-slash.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/roundhouse-slash.png-8194ab7ba1d330f17df8f9dfd3e6468a.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/roundhouse-slash.png"
+dest_files=[ "res://.import/roundhouse-slash.png-8194ab7ba1d330f17df8f9dfd3e6468a.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/slash-effect.png b/Assets/SkillAnimation/slash-effect.png
new file mode 100644
index 0000000..229dca7
Binary files /dev/null and b/Assets/SkillAnimation/slash-effect.png differ
diff --git a/Assets/SkillAnimation/slash-effect.png.import b/Assets/SkillAnimation/slash-effect.png.import
new file mode 100644
index 0000000..0e57cc9
--- /dev/null
+++ b/Assets/SkillAnimation/slash-effect.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/slash-effect.png-a39f77a5b4b6117d9c824bfa5b5046cb.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/slash-effect.png"
+dest_files=[ "res://.import/slash-effect.png-a39f77a5b4b6117d9c824bfa5b5046cb.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Assets/SkillAnimation/super_arrow.png b/Assets/SkillAnimation/super_arrow.png
new file mode 100644
index 0000000..d615818
Binary files /dev/null and b/Assets/SkillAnimation/super_arrow.png differ
diff --git a/Assets/SkillAnimation/super_arrow.png.import b/Assets/SkillAnimation/super_arrow.png.import
new file mode 100644
index 0000000..ca983de
--- /dev/null
+++ b/Assets/SkillAnimation/super_arrow.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/super_arrow.png-c8853dbab1a97b880a75245e7cae8153.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://Assets/SkillAnimation/super_arrow.png"
+dest_files=[ "res://.import/super_arrow.png-c8853dbab1a97b880a75245e7cae8153.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+process/normal_map_invert_y=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/Scripts/Auth/PlayerProfileSnapshot.cs b/Scripts/Auth/PlayerProfileSnapshot.cs
index 1230a45..15bf248 100644
--- a/Scripts/Auth/PlayerProfileSnapshot.cs
+++ b/Scripts/Auth/PlayerProfileSnapshot.cs
@@ -410,7 +410,7 @@ public class PlayerProfileSnapshot
public Godot.Collections.Array DiscardedItems { get; set; } = new Godot.Collections.Array();
public Godot.Collections.Dictionary EquippedItemsPayload { get; set; } = new Godot.Collections.Dictionary();
- /// Serialised class name, e.g. "adventurer", "mage", "archer", "warrior".
+ /// Serialised class name, e.g. "adventurer", "mage", "archer", "knight".
public string ClassName { get; set; } = "adventurer";
public List Skills { get; set; } = new List();
@@ -467,6 +467,9 @@ public static PlayerProfileSnapshot FromDictionary(Godot.Collections.Dictionary
DisplayOrder = ReadInt(skillDict, "display_order", i, min: 0),
});
}
+
+ // Sort skills in-place by DisplayOrder to guarantee correct sequence on frontend load
+ snapshot.Skills.Sort((a, b) => a.DisplayOrder.CompareTo(b.DisplayOrder));
}
if (snapshot.Skills.Count == 0)
@@ -477,6 +480,34 @@ public static PlayerProfileSnapshot FromDictionary(Godot.Collections.Dictionary
return snapshot;
}
+ private static Godot.Collections.Array EncodeSkills(List skills)
+ {
+ var array = new Godot.Collections.Array();
+ if (skills == null)
+ {
+ return array;
+ }
+
+ foreach (var skill in skills)
+ {
+ if (skill == null)
+ {
+ continue;
+ }
+
+ var dict = new Godot.Collections.Dictionary
+ {
+ ["skill_id"] = skill.SkillId ?? string.Empty,
+ ["name"] = skill.Name ?? string.Empty,
+ ["cooldown_seconds"] = skill.CooldownSeconds,
+ ["display_order"] = skill.DisplayOrder,
+ };
+ array.Add(dict);
+ }
+
+ return array;
+ }
+
public Godot.Collections.Dictionary ToUpdatePayload(string sessionId, int sequence)
{
return new Godot.Collections.Dictionary
@@ -492,6 +523,7 @@ public Godot.Collections.Dictionary ToUpdatePayload(string sessionId, int sequen
["inventory_items"] = InventoryItems ?? new Godot.Collections.Array(),
["discarded_items"] = DiscardedItems ?? new Godot.Collections.Array(),
["equipped_items"] = EquippedItemsPayload ?? new Godot.Collections.Dictionary(),
+ ["skills"] = EncodeSkills(Skills),
};
}
diff --git a/Scripts/Characters/Character.cs b/Scripts/Characters/Character.cs
index 7178603..902c48e 100644
--- a/Scripts/Characters/Character.cs
+++ b/Scripts/Characters/Character.cs
@@ -93,7 +93,7 @@ public virtual void UpdateAttributes()
/// Apply damage to this character.
/// Blocked entirely when is true.
///
- public virtual void TakeDamage(int damage)
+ public virtual void TakeDamage(int damage, Character source = null)
{
// Invincibility guard — effect-based immunity
if (IsInvincible) return;
diff --git a/Scripts/Characters/Monster.cs b/Scripts/Characters/Monster.cs
index 1a0cbd8..49e14a1 100644
--- a/Scripts/Characters/Monster.cs
+++ b/Scripts/Characters/Monster.cs
@@ -211,9 +211,9 @@ public override void UpdateAttributes()
Attributes.TotalDef = 0;
}
- public override void TakeDamage(int damage)
+ public override void TakeDamage(int damage, Character source = null)
{
- base.TakeDamage(damage);
+ base.TakeDamage(damage, source);
if (!_isDead && Attributes?.HP != null && Attributes.HP.IsAlive)
{
_isHit = true;
@@ -815,7 +815,7 @@ public override void Attack()
{
// EffectiveAtk respects Burn debuff (ATK reduction while burning)
int damage = Attributes?.EffectiveAtk ?? 1;
- _player.TakeDamage(damage);
+ _player.TakeDamage(damage, this);
GD.Print($"[COMBAT] {EntityName} attacks Player for {damage} damage! Player HP: {_player.Attributes.HP.CurrentHP}/{_player.Attributes.HP.MaxHP}");
}
}
diff --git a/Scripts/Characters/Player.cs b/Scripts/Characters/Player.cs
index 8bc5e6f..ee8b165 100644
--- a/Scripts/Characters/Player.cs
+++ b/Scripts/Characters/Player.cs
@@ -66,6 +66,7 @@ public class Player : Character
// Invincibility state
private int _damageCooldownFrames = 0;
private float _respawnInvincibilityTimer = 0f;
+ private float _defenseStanceTimer = 0f;
// Previously exposed properties now delegated to subsystems
public int Experience => _inventorySystem?.Experience ?? 0;
@@ -244,6 +245,13 @@ public int GetSelectedSkillIndex()
public System.Collections.Generic.IReadOnlyList- DiscardedItems =>
_inventorySystem?.Discarded?.Items.AsReadOnly() ?? new System.Collections.Generic.List
- ().AsReadOnly();
+ public void ActivateDefenseStance(float duration)
+ {
+ _defenseStanceTimer = duration;
+ _animationController?.PlayDefenseAnimation();
+ GD.Print($"[Player] Defense Stance activated for {duration} seconds.");
+ }
+
///
/// Set the map reference and initialize room tracking
///
@@ -289,14 +297,24 @@ public override void UpdateAttributes()
Attributes.TotalVit = jobBonuses.Vit + equipmentBonuses.Vit;
}
- public override void TakeDamage(int damage)
+ public override void TakeDamage(int damage, Character source = null)
{
+ if (_defenseStanceTimer > 0f)
+ {
+ if (source != null && source.Attributes?.HP?.IsAlive == true)
+ {
+ source.TakeDamage(Attributes.EffectiveAtk);
+ _animationController?.PlayDefenseCounterAnimation(0.2f);
+ }
+ return;
+ }
+
if (_respawnInvincibilityTimer > 0f) return;
if (_damageCooldownFrames > 0) return;
_damageCooldownFrames = 6; // 0.1 seconds at 60 FPS processing
- base.TakeDamage(damage);
+ base.TakeDamage(damage, source);
if (!_isDead && Attributes?.HP != null && Attributes.HP.IsAlive)
{
_animationController?.PlayHitAnimation(_hitTexture, 0.2f);
@@ -352,8 +370,17 @@ public override void _PhysicsProcess(float delta)
if (_map == null)
return;
+ if (_defenseStanceTimer > 0f)
+ {
+ _defenseStanceTimer -= delta;
+ if (_defenseStanceTimer <= 0f)
+ {
+ _animationController?.StopDefenseAnimation();
+ }
+ }
+
// Get current movement input
- Vector2 movementInput = _inputHandler.GetMovementInput();
+ Vector2 movementInput = _defenseStanceTimer > 0f ? Vector2.Zero : _inputHandler.GetMovementInput();
// 1. Handle physics and movement
_physicsController.Update(
@@ -475,7 +502,29 @@ public void ApplyProfile(PlayerProfileSnapshot snapshot)
PlayerClass restoredClass = PlayerClassData.Deserialize(snapshot.ClassName);
SetClass(restoredClass, rebuildSkills: false);
- _combatSystem?.SetSkills(BuildSkillsFromSnapshot(snapshot.Skills, restoredClass));
+ var newSkills = BuildSkillsFromSnapshot(snapshot.Skills, restoredClass);
+
+ // Check if the new list of skills matches our current skills in ID and order exactly.
+ // If they match, keep the current live skill instances to avoid resetting their local cooldowns.
+ var currentSkills = _combatSystem?.CurrentSkills;
+ bool skillsMatch = false;
+ if (currentSkills != null && currentSkills.Count == newSkills.Count)
+ {
+ skillsMatch = true;
+ for (int i = 0; i < currentSkills.Count; i++)
+ {
+ if (ResolveSkillId(currentSkills[i]) != ResolveSkillId(newSkills[i]))
+ {
+ skillsMatch = false;
+ break;
+ }
+ }
+ }
+
+ if (!skillsMatch)
+ {
+ _combatSystem?.SetSkills(newSkills);
+ }
// Re-broadcast HP to refresh HUD after profile application.
if (Attributes?.HP != null)
@@ -560,33 +609,20 @@ private List BuildSkillsFromSnapshot(IReadOnlyList
continue;
}
- if (string.Equals(snapshot.SkillId, "basic_attack", StringComparison.OrdinalIgnoreCase))
- {
- var basicAttack = new BasicAttackSkill
- {
- EffectRenderer = new BasicAttackEffectRenderer(),
- };
- skills.Add(basicAttack);
- continue;
- }
-
- if (string.Equals(snapshot.SkillId, "bow_attack", StringComparison.OrdinalIgnoreCase))
+ var skill = CreateSkillFromId(snapshot.SkillId);
+ if (skill == null)
{
- skills.Add(new BowAttackSkill());
- continue;
+ // Fall back to remote skill mapping if it's a remote/external skill not recognized locally
+ skill = new RemoteSkill(
+ snapshot.SkillId,
+ snapshot.Name,
+ snapshot.CooldownSeconds);
}
- if (string.Equals(snapshot.SkillId, "fireball", StringComparison.OrdinalIgnoreCase))
+ if (skill != null)
{
- skills.Add(new FireballSkill());
- continue;
+ skills.Add(skill);
}
-
- var remoteSkill = new RemoteSkill(
- snapshot.SkillId,
- snapshot.Name,
- snapshot.CooldownSeconds);
- skills.Add(remoteSkill);
}
EnsureClassCoreSkills(skills, cls);
@@ -627,6 +663,66 @@ private static string ResolveSkillId(Skills skill)
return "fireball";
}
+ if (skill is TripleFireballSkill)
+ {
+ return "triple_fireball";
+ }
+
+ if (skill is GiantFireballSkill)
+ {
+ return "giant_fireball";
+ }
+
+ if (skill is TripleArrowSkill)
+ {
+ return "triple_arrow";
+ }
+
+ if (skill is RicochetArrowSkill)
+ {
+ return "ricochet_arrow";
+ }
+
+ if (skill is FlyingSwordSkill)
+ {
+ return "flying_sword";
+ }
+
+ if (skill is DefenseStanceSkill)
+ {
+ return "defense_stance";
+ }
+
+ if (skill is MagicSlashSkill)
+ {
+ return "magic_slash";
+ }
+
+ if (skill is IceSpearSkill)
+ {
+ return "ice_spear";
+ }
+
+ if (skill is DigitArrowSkill)
+ {
+ return "digit_arrow";
+ }
+
+ if (skill is SuperArrowSkill)
+ {
+ return "super_arrow";
+ }
+
+ if (skill is RoundhouseSlashSkill)
+ {
+ return "roundhouse_slash";
+ }
+
+ if (skill is KnightExploseSkill)
+ {
+ return "knight_explose";
+ }
+
if (skill is RemoteSkill remoteSkill)
{
return remoteSkill.SkillId;
@@ -697,28 +793,19 @@ private static void EnsureClassCoreSkills(List skills, PlayerClass cls)
// Remove skills that are not allowed for this class
skills.RemoveAll(s => !allowed.Contains(ResolveSkillId(s)));
- // Ensure the class's primary skill exists
- bool hasSword = skills.Any(s => s is BasicAttackSkill);
- bool hasBow = skills.Any(s => s is BowAttackSkill);
- bool hasFireball = skills.Any(s => s is FireballSkill);
-
- if (allowed.Contains(PlayerClassData.SkillIdSword) && !hasSword)
+ // If the player currently has no skills (newly initialized or class changed),
+ // populate with the class's default 3-skill loadout.
+ if (skills.Count == 0)
{
- var basicAttack = new BasicAttackSkill
+ var defaults = PlayerClassData.GetDefaultSkillLoadout(cls);
+ foreach (var skillId in defaults)
{
- EffectRenderer = new BasicAttackEffectRenderer(),
- };
- skills.Insert(0, basicAttack);
- }
-
- if (allowed.Contains(PlayerClassData.SkillIdBow) && !hasBow)
- {
- skills.Add(new BowAttackSkill());
- }
-
- if (allowed.Contains(PlayerClassData.SkillIdFireball) && !hasFireball)
- {
- skills.Add(new FireballSkill());
+ var skill = CreateSkillFromId(skillId);
+ if (skill != null)
+ {
+ skills.Add(skill);
+ }
+ }
}
}
@@ -753,10 +840,19 @@ private void ApplyClassSkills(PlayerClass cls)
return;
}
- var current = _combatSystem.CurrentSkills.ToList();
- EnsureClassCoreSkills(current, cls);
- _combatSystem.SetSkills(current);
- GD.Print($"[Player] Skills rebuilt for class {cls}: {string.Join(", ", current.Select(ResolveSkillId))}");
+ var defaultSkills = new List();
+ var defaults = PlayerClassData.GetDefaultSkillLoadout(cls);
+ foreach (var skillId in defaults)
+ {
+ var skill = CreateSkillFromId(skillId);
+ if (skill != null)
+ {
+ defaultSkills.Add(skill);
+ }
+ }
+
+ _combatSystem.SetSkills(defaultSkills);
+ GD.Print($"[Player] Skills rebuilt for class {cls}: {string.Join(", ", defaultSkills.Select(ResolveSkillId))}");
}
///
@@ -811,10 +907,14 @@ string Resolve(string primary, string fb) =>
// Reload class-specific hit / dead textures (with shared fallback).
string hitPath = Resolve(paths.HitFrame, fallback.HitFrame);
string deadPath = Resolve(paths.DeadFrame, fallback.DeadFrame);
+ string defPath = Resolve(paths.DefenseFrame, fallback.DefenseFrame);
+ string atkPath = Resolve(paths.SkillAttackFrame, fallback.SkillAttackFrame);
_hitTexture = GD.Load(hitPath);
_deadTexture = GD.Load(deadPath);
+ _animationController?.LoadDefenseTextures(defPath, atkPath);
+
string bowFrame = paths.BowAttackPaths != null ? paths.BowAttackPaths[0] : "default";
GD.Print($"[Player] Sprites updated for class {cls}. Stand={stand1}, Attack={attack1}, Bow={bowFrame}, Hit={hitPath}, Dead={deadPath}");
}
@@ -893,6 +993,11 @@ private void RestoreEquippedItems(Godot.Collections.Dictionary data)
///
public bool IsAttacking => _animationController?.IsAttacking ?? false;
+ ///
+ /// Expose player's facing direction (1.0f for right, -1.0f for left).
+ ///
+ public float FacingDirection => _animationController?.GetFacingDirection() ?? 1f;
+
///
/// Update skill cooldowns (called by PhysicsController)
///
@@ -920,6 +1025,133 @@ public void LearnSkill(Skills skill)
_combatSystem?.LearnSkill(skill);
}
+ ///
+ /// Replace the player's equipped skill list with the given ordered list of skill IDs,
+ /// enforcing class restrictions. Called from the skill-equip UI after the player
+ /// saves their loadout. Invalid / disallowed IDs are silently skipped.
+ ///
+ public void SetEquippedSkills(System.Collections.Generic.List orderedSkillIds)
+ {
+ if (_combatSystem == null) return;
+
+ var builtSkills = BuildSkillsFromIds(orderedSkillIds, PlayerClass);
+ _combatSystem.SetSkills(builtSkills);
+ GD.Print($"[Player] Skill loadout updated: {string.Join(", ", orderedSkillIds)}");
+ }
+
+ ///
+ /// Returns the ordered list of skill IDs currently equipped by the player.
+ ///
+ public System.Collections.Generic.List GetEquippedSkillIds()
+ {
+ var ids = new System.Collections.Generic.List();
+ var skills = _combatSystem?.CurrentSkills;
+ if (skills == null) return ids;
+ foreach (var s in skills)
+ {
+ if (s != null) ids.Add(ResolveSkillId(s));
+ }
+ return ids;
+ }
+
+ ///
+ /// Creates a Skills instance based on the unique skill ID string.
+ ///
+ private static Skills CreateSkillFromId(string key)
+ {
+ string cleanKey = (key ?? string.Empty).ToLowerInvariant();
+ if (cleanKey == "basic_attack")
+ {
+ return new BasicAttackSkill { EffectRenderer = new BasicAttackEffectRenderer() };
+ }
+ if (cleanKey == "bow_attack")
+ {
+ return new BowAttackSkill();
+ }
+ if (cleanKey == "triple_arrow")
+ {
+ return new TripleArrowSkill();
+ }
+ if (cleanKey == "ricochet_arrow")
+ {
+ return new RicochetArrowSkill();
+ }
+ if (cleanKey == "fireball")
+ {
+ return new FireballSkill();
+ }
+ if (cleanKey == "triple_fireball")
+ {
+ return new TripleFireballSkill();
+ }
+ if (cleanKey == "giant_fireball")
+ {
+ return new GiantFireballSkill();
+ }
+ if (cleanKey == "flying_sword")
+ {
+ return new FlyingSwordSkill();
+ }
+ if (cleanKey == "defense_stance")
+ {
+ return new DefenseStanceSkill();
+ }
+ if (cleanKey == "magic_slash")
+ {
+ return new MagicSlashSkill();
+ }
+ if (cleanKey == "ice_spear")
+ {
+ return new IceSpearSkill();
+ }
+ if (cleanKey == "digit_arrow")
+ {
+ return new DigitArrowSkill();
+ }
+ if (cleanKey == "super_arrow")
+ {
+ return new SuperArrowSkill();
+ }
+ if (cleanKey == "roundhouse_slash")
+ {
+ return new RoundhouseSlashSkill();
+ }
+ if (cleanKey == "knight_explose")
+ {
+ return new KnightExploseSkill();
+ }
+ return null;
+ }
+
+ ///
+ /// Translates an ordered list of skill IDs into live skill instances,
+ /// filtering out any IDs not allowed for the given class.
+ ///
+ private static System.Collections.Generic.List BuildSkillsFromIds(
+ System.Collections.Generic.List orderedIds,
+ PlayerClass cls)
+ {
+ var allowed = PlayerClassData.GetAllowedSkillIds(cls);
+ var skills = new System.Collections.Generic.List();
+
+ if (orderedIds != null)
+ {
+ foreach (var id in orderedIds)
+ {
+ string key = (id ?? string.Empty).ToLowerInvariant();
+ if (!allowed.Contains(key)) continue;
+
+ var skill = CreateSkillFromId(key);
+ if (skill != null)
+ {
+ skills.Add(skill);
+ }
+ }
+ }
+
+ return skills;
+ }
+
// ==================== Inventory System ====================
///
/// Gain experience points
diff --git a/Scripts/Characters/PlayerSystems/PlayerAnimationController.cs b/Scripts/Characters/PlayerSystems/PlayerAnimationController.cs
index bc8fa1d..45604ee 100644
--- a/Scripts/Characters/PlayerSystems/PlayerAnimationController.cs
+++ b/Scripts/Characters/PlayerSystems/PlayerAnimationController.cs
@@ -7,6 +7,8 @@ public enum AttackAnimationStyle
Sword,
Bow,
Fireball,
+ ArcherShot,
+ KnightExplose,
}
///
@@ -27,6 +29,11 @@ public class PlayerAnimationController
private Texture[] _swordAttackFrames;
private Texture[] _bowAttackFrames;
private Texture[] _fireballAttackFrames;
+ private readonly Texture[] _archerShotFrames;
+ private readonly Texture[] _knightExploseFrames;
+
+ private Texture _defenseTexture;
+ private Texture _counterTexture;
// Default (Adventurer) frame paths for each skill style.
private static readonly string[] DefaultSwordPaths = new[]
@@ -65,6 +72,16 @@ public PlayerAnimationController(
_bowAttackFrames = BuildFrames(DefaultBowPaths[0], DefaultBowPaths[1], DefaultBowPaths[2]);
_fireballAttackFrames = BuildFrames(DefaultFireballPaths[0], DefaultFireballPaths[1], DefaultFireballPaths[2]);
+
+ _archerShotFrames = new[]
+ {
+ GD.Load("res://Assets/Characters/archer/shot.png"),
+ GD.Load("res://Assets/Characters/archer/shot1.png")
+ };
+ _knightExploseFrames = new[]
+ {
+ GD.Load("res://Assets/Characters/warrior/super_attack.png")
+ };
}
///
@@ -135,6 +152,14 @@ public void PlayAttackAnimation(AttackAnimationStyle style = AttackAnimationStyl
{
selectedFrames = _fireballAttackFrames;
}
+ else if (style == AttackAnimationStyle.ArcherShot)
+ {
+ selectedFrames = _archerShotFrames;
+ }
+ else if (style == AttackAnimationStyle.KnightExplose)
+ {
+ selectedFrames = _knightExploseFrames;
+ }
_animationSystem.SetAttackFrames(selectedFrames);
_animationSystem.PlayAttackAnimation();
@@ -172,6 +197,29 @@ public void UpdateClassFrames(string[] swordPaths, string[] bowPaths, string[] f
GD.Print("[PlayerAnimationController] Attack frames updated for class.");
}
+ public void LoadDefenseTextures(string defensePath, string counterPath)
+ {
+ _defenseTexture = !string.IsNullOrEmpty(defensePath) ? GD.Load(defensePath) : null;
+ _counterTexture = !string.IsNullOrEmpty(counterPath) ? GD.Load(counterPath) : null;
+ }
+
+ public void PlayDefenseAnimation()
+ {
+ if (_defenseTexture != null)
+ _animationSystem.PlayDefenseAnimation(_defenseTexture);
+ }
+
+ public void PlayDefenseCounterAnimation(float duration = 0.2f)
+ {
+ if (_counterTexture != null)
+ _animationSystem.PlayDefenseCounterAnimation(_counterTexture, duration);
+ }
+
+ public void StopDefenseAnimation()
+ {
+ _animationSystem.StopDefenseAnimation();
+ }
+
///
/// Trigger dead animation playback
///
diff --git a/Scripts/Characters/PlayerSystems/PlayerAnimationSystem.cs b/Scripts/Characters/PlayerSystems/PlayerAnimationSystem.cs
index 40697e5..e2ddd22 100644
--- a/Scripts/Characters/PlayerSystems/PlayerAnimationSystem.cs
+++ b/Scripts/Characters/PlayerSystems/PlayerAnimationSystem.cs
@@ -11,7 +11,8 @@ public enum AnimationState
Walking, // Moving around
Attacking, // Performing basic attack
Dead, // Character is dead
- Hit // Character took damage
+ Hit, // Character took damage
+ Defending // Character is in defense stance
}
public class PlayerAnimationSystem
@@ -37,6 +38,11 @@ public class PlayerAnimationSystem
private Texture _hitTexture;
private float _hitTimer = 0f;
+ // Defense animation
+ private Texture _defenseTexture;
+ private Texture _counterTexture;
+ private float _counterTimer = 0f;
+
private AnimationState _currentState = AnimationState.Idle;
private int _frameIndex = 0;
private float _animationTimer = 0f;
@@ -190,6 +196,16 @@ public void UpdateAnimation(bool isMoving, float delta, float walkAnimationFps,
}
}
+ if (_currentState == AnimationState.Defending)
+ {
+ if (_counterTimer > 0f)
+ {
+ _counterTimer -= delta;
+ }
+ ApplyCurrentFrame(facingX);
+ return;
+ }
+
// Determine target state
AnimationState targetState = isMoving ? AnimationState.Walking : AnimationState.Idle;
@@ -311,6 +327,34 @@ public void Revive()
}
}
+ public void PlayDefenseAnimation(Texture defenseTexture)
+ {
+ if (_currentState == AnimationState.Dead) return;
+
+ _currentState = AnimationState.Defending;
+ _defenseTexture = defenseTexture;
+ _counterTimer = 0f;
+ ApplyCurrentFrame(1f);
+ }
+
+ public void PlayDefenseCounterAnimation(Texture counterTexture, float duration)
+ {
+ if (_currentState != AnimationState.Defending) return;
+
+ _counterTexture = counterTexture;
+ _counterTimer = duration;
+ ApplyCurrentFrame(1f);
+ }
+
+ public void StopDefenseAnimation()
+ {
+ if (_currentState == AnimationState.Defending)
+ {
+ _currentState = AnimationState.Idle;
+ ApplyCurrentFrame(1f);
+ }
+ }
+
///
/// Update attack animation and return true when finished
///
@@ -380,6 +424,16 @@ private void ApplyCurrentFrame(float facingX)
frameToUse = _hitTexture;
}
break;
+ case AnimationState.Defending:
+ if (_counterTimer > 0f && _counterTexture != null)
+ {
+ frameToUse = _counterTexture;
+ }
+ else if (_defenseTexture != null)
+ {
+ frameToUse = _defenseTexture;
+ }
+ break;
}
if (frameToUse != null)
diff --git a/Scripts/Characters/PlayerSystems/PlayerCombatController.cs b/Scripts/Characters/PlayerSystems/PlayerCombatController.cs
index 6d6bca0..7fb5eea 100644
--- a/Scripts/Characters/PlayerSystems/PlayerCombatController.cs
+++ b/Scripts/Characters/PlayerSystems/PlayerCombatController.cs
@@ -125,16 +125,26 @@ private bool ExecuteSkill(Player player, Map map, int skillIndex)
private static AttackAnimationStyle GetAttackAnimationStyle(Skills skill)
{
- if (skill is BowAttackSkill)
+ if (skill is BowAttackSkill || skill is TripleArrowSkill || skill is RicochetArrowSkill)
{
return AttackAnimationStyle.Bow;
}
- if (skill is FireballSkill)
+ if (skill is DigitArrowSkill || skill is SuperArrowSkill)
+ {
+ return AttackAnimationStyle.ArcherShot;
+ }
+
+ if (skill is FireballSkill || skill is TripleFireballSkill || skill is GiantFireballSkill || skill is IceSpearSkill || skill is MagicSlashSkill)
{
return AttackAnimationStyle.Fireball;
}
+ if (skill is KnightExploseSkill)
+ {
+ return AttackAnimationStyle.KnightExplose;
+ }
+
return AttackAnimationStyle.Sword;
}
diff --git a/Scripts/Core/Data/PlayerClass.cs b/Scripts/Core/Data/PlayerClass.cs
index e525491..b25182c 100644
--- a/Scripts/Core/Data/PlayerClass.cs
+++ b/Scripts/Core/Data/PlayerClass.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Collections.ObjectModel;
namespace QuestFantasy.Core.Data
{
@@ -11,7 +12,7 @@ public enum PlayerClass
Adventurer,
Mage,
Archer,
- Warrior
+ Knight
}
///
@@ -46,6 +47,9 @@ public class ClassSpritePaths
// Death / knocked-down frame (optional)
public string DeadFrame { get; set; }
+ public string DefenseFrame { get; set; }
+ public string SkillAttackFrame { get; set; }
+
///
/// Uniform visual scale multiplier applied on top of the body-size calculation.
/// 1.0 = normal size, 0.8 = 20 % smaller, etc.
@@ -54,6 +58,19 @@ public class ClassSpritePaths
public float ScaleMultiplier { get; set; } = 1.0f;
}
+ ///
+ /// Immutable descriptor for a single learnable skill.
+ /// Used by the skill-equip UI to build cards without coupling to concrete skill classes.
+ ///
+ public class SkillDefinition
+ {
+ public string Id { get; set; }
+ public string DisplayName { get; set; }
+ public string Description { get; set; }
+ public string Emoji { get; set; }
+ public float CooldownSec { get; set; }
+ }
+
///
/// Static registry of per-class data:
/// - Which skill IDs are allowed
@@ -86,6 +103,18 @@ public static class PlayerClassData
public const string SkillIdSword = "basic_attack";
public const string SkillIdBow = "bow_attack";
public const string SkillIdFireball = "fireball";
+ public const string SkillIdTripleFireball = "triple_fireball";
+ public const string SkillIdGiantFireball = "giant_fireball";
+ public const string SkillIdTripleArrow = "triple_arrow";
+ public const string SkillIdRicochetArrow = "ricochet_arrow";
+ public const string SkillIdFlyingSword = "flying_sword";
+ public const string SkillIdDefenseStance = "defense_stance";
+ public const string SkillIdMagicSlash = "magic_slash";
+ public const string SkillIdIceSpear = "ice_spear";
+ public const string SkillIdDigitArrow = "digit_arrow";
+ public const string SkillIdSuperArrow = "super_arrow";
+ public const string SkillIdRoundhouseSlash = "roundhouse_slash";
+ public const string SkillIdKnightExplose = "knight_explose";
// ── Allowed skills per class ───────────────────────────────────────
@@ -96,17 +125,17 @@ public static class PlayerClassData
private static readonly HashSet MageSkills = new HashSet
{
- SkillIdFireball
+ SkillIdFireball, SkillIdTripleFireball, SkillIdGiantFireball, SkillIdMagicSlash, SkillIdIceSpear
};
private static readonly HashSet ArcherSkills = new HashSet
{
- SkillIdBow
+ SkillIdBow, SkillIdTripleArrow, SkillIdRicochetArrow, SkillIdDigitArrow, SkillIdSuperArrow
};
- private static readonly HashSet WarriorSkills = new HashSet
+ private static readonly HashSet KnightSkills = new HashSet
{
- SkillIdSword
+ SkillIdSword, SkillIdFlyingSword, SkillIdDefenseStance, SkillIdRoundhouseSlash, SkillIdKnightExplose
};
///
@@ -118,7 +147,7 @@ public static HashSet GetAllowedSkillIds(PlayerClass cls)
{
case PlayerClass.Mage: return MageSkills;
case PlayerClass.Archer: return ArcherSkills;
- case PlayerClass.Warrior: return WarriorSkills;
+ case PlayerClass.Knight: return KnightSkills;
default: return AdventurerSkills;
}
}
@@ -130,7 +159,7 @@ public static string GetDisplayName(PlayerClass cls)
{
case PlayerClass.Mage: return "Mage";
case PlayerClass.Archer: return "Archer";
- case PlayerClass.Warrior: return "Warrior";
+ case PlayerClass.Knight: return "Knight";
default: return "Adventurer";
}
}
@@ -144,7 +173,7 @@ public static string GetDescription(PlayerClass cls)
return "Wields the arcane arts.\nMasters the art of Fireball magic.";
case PlayerClass.Archer:
return "Swift and precise.\nStrikes enemies from a distance with arrows.";
- case PlayerClass.Warrior:
+ case PlayerClass.Knight:
return "Unyielding and fierce.\nCleaves foes with a powerful sword slash.";
default:
return "The all-rounder.\nCommands sword, bow, and magic freely.";
@@ -158,11 +187,182 @@ public static string GetSkillListText(PlayerClass cls)
{
case PlayerClass.Mage: return "Fireball";
case PlayerClass.Archer: return "Arrow Shot";
- case PlayerClass.Warrior: return "Sword Slash";
+ case PlayerClass.Knight: return "Sword Slash";
default: return "Sword Slash, Arrow Shot, Fireball";
}
}
+ // ── Skill definition catalogue ─────────────────────────────────────
+
+ private static readonly SkillDefinition DefSword = new SkillDefinition
+ {
+ Id = SkillIdSword,
+ DisplayName = "Sword Slash",
+ Description = "A powerful melee strike that hits nearby enemies.",
+ Emoji = "⚔️",
+ CooldownSec = 0.3f,
+ };
+
+ private static readonly SkillDefinition DefBow = new SkillDefinition
+ {
+ Id = SkillIdBow,
+ DisplayName = "Arrow Shot",
+ Description = "Fire an arrow that pierces enemies at range.",
+ Emoji = "🏹",
+ CooldownSec = 0.8f,
+ };
+
+ private static readonly SkillDefinition DefTripleArrow = new SkillDefinition
+ {
+ Id = SkillIdTripleArrow,
+ DisplayName = "Triple Arrow",
+ Description = "Fire 3 parallel arrows at once that pierce enemies.",
+ Emoji = "🏹",
+ CooldownSec = 1.5f,
+ };
+
+ private static readonly SkillDefinition DefRicochetArrow = new SkillDefinition
+ {
+ Id = SkillIdRicochetArrow,
+ DisplayName = "Ricochet Arrow",
+ Description = "Fire an arrow that bounces between enemies and walls.",
+ Emoji = "↪️",
+ CooldownSec = 2.5f,
+ };
+
+ private static readonly SkillDefinition DefFireball = new SkillDefinition
+ {
+ Id = SkillIdFireball,
+ DisplayName = "Fireball",
+ Description = "Launch a fireball that explodes on impact and may Burn.",
+ Emoji = "🔥",
+ CooldownSec = 1.2f,
+ };
+
+ private static readonly SkillDefinition DefTripleFireball = new SkillDefinition
+ {
+ Id = SkillIdTripleFireball,
+ DisplayName = "Triple Fireball",
+ Description = "Launch 3 fireballs in a spread that explode on impact.",
+ Emoji = "☄️",
+ CooldownSec = 2.0f,
+ };
+
+ private static readonly SkillDefinition DefGiantFireball = new SkillDefinition
+ {
+ Id = SkillIdGiantFireball,
+ DisplayName = "Giant Fireball",
+ Description = "Launch a massive fireball that explodes on impact.",
+ Emoji = "🔥",
+ CooldownSec = 3.0f,
+ };
+
+ private static readonly SkillDefinition DefFlyingSword = new SkillDefinition
+ {
+ Id = SkillIdFlyingSword,
+ DisplayName = "Flying Sword",
+ Description = "Throw a sword that pierces enemies and returns to you.",
+ Emoji = "🗡️",
+ CooldownSec = 1.5f,
+ };
+
+ private static readonly SkillDefinition DefDefenseStance = new SkillDefinition
+ {
+ Id = SkillIdDefenseStance,
+ DisplayName = "Defense Stance",
+ Description = "Block all damage and counter-attack enemies when hit.",
+ Emoji = "🛡️",
+ CooldownSec = 8.0f,
+ };
+
+ private static readonly SkillDefinition DefMagicSlash = new SkillDefinition
+ {
+ Id = SkillIdMagicSlash,
+ DisplayName = "Magic Slash",
+ Description = "Strike with arcane energy, cutting through magic.",
+ Emoji = "✨",
+ CooldownSec = 1.0f,
+ };
+
+ private static readonly SkillDefinition DefIceSpear = new SkillDefinition
+ {
+ Id = SkillIdIceSpear,
+ DisplayName = "Ice Spear",
+ Description = "Launch a spear of ice that pierces and freezes enemies.",
+ Emoji = "❄️",
+ CooldownSec = 1.8f,
+ };
+
+ private static readonly SkillDefinition DefDigitArrow = new SkillDefinition
+ {
+ Id = SkillIdDigitArrow,
+ DisplayName = "Digit Arrow",
+ Description = "Fire an arrow that passes through obstacles and enemies, traveling a set distance.",
+ Emoji = "🏹",
+ CooldownSec = 1.2f,
+ };
+
+ private static readonly SkillDefinition DefSuperArrow = new SkillDefinition
+ {
+ Id = SkillIdSuperArrow,
+ DisplayName = "Super Arrow",
+ Description = "Fire a powerful arrow that paralyzes enemies for 5 seconds.",
+ Emoji = "💫",
+ CooldownSec = 10.0f,
+ };
+
+ private static readonly SkillDefinition DefRoundhouseSlash = new SkillDefinition
+ {
+ Id = SkillIdRoundhouseSlash,
+ DisplayName = "Roundhouse Slash",
+ Description = "Spin and strike all nearby enemies with a powerful slash.",
+ Emoji = "⚡",
+ CooldownSec = 2.0f,
+ };
+
+ private static readonly SkillDefinition DefKnightExplose = new SkillDefinition
+ {
+ Id = SkillIdKnightExplose,
+ DisplayName = "Knight Explosion",
+ Description = "Create an expanding explosion at your location that damages enemies inside it repeatedly.",
+ Emoji = "💥",
+ CooldownSec = 30.0f,
+ };
+
+ ///
+ /// Returns the ordered list of all s that
+ /// the given class is permitted to equip. Used to build the skill-equip UI.
+ ///
+ public static ReadOnlyCollection GetAllSkillDefinitions(PlayerClass cls)
+ {
+ switch (cls)
+ {
+ case PlayerClass.Mage:
+ return new ReadOnlyCollection(new[] { DefFireball, DefTripleFireball, DefGiantFireball, DefMagicSlash, DefIceSpear });
+ case PlayerClass.Archer:
+ return new ReadOnlyCollection(new[] { DefBow, DefTripleArrow, DefRicochetArrow, DefDigitArrow, DefSuperArrow });
+ case PlayerClass.Knight:
+ return new ReadOnlyCollection(new[] { DefSword, DefFlyingSword, DefDefenseStance, DefRoundhouseSlash, DefKnightExplose });
+ default: // Adventurer
+ return new ReadOnlyCollection(new[] { DefSword, DefBow, DefFireball });
+ }
+ }
+
+ ///
+ /// Returns the default ordered skill-ID loadout for .
+ /// Used to reset the loadout when the player changes class.
+ ///
+ public static ReadOnlyCollection GetDefaultSkillLoadout(PlayerClass cls)
+ {
+ switch (cls)
+ {
+ case PlayerClass.Mage: return new ReadOnlyCollection(new[] { SkillIdFireball, SkillIdMagicSlash, SkillIdIceSpear });
+ case PlayerClass.Archer: return new ReadOnlyCollection(new[] { SkillIdBow, SkillIdDigitArrow, SkillIdSuperArrow });
+ case PlayerClass.Knight: return new ReadOnlyCollection(new[] { SkillIdSword, SkillIdRoundhouseSlash, SkillIdKnightExplose });
+ default: return new ReadOnlyCollection(new[] { SkillIdSword, SkillIdBow, SkillIdFireball });
+ }
+ }
+
///
/// Returns the explicit sprite paths for each class.
/// Paths that don't exist on disk are handled gracefully in
@@ -215,7 +415,7 @@ public static ClassSpritePaths GetSpritePaths(PlayerClass cls)
};
}
- case PlayerClass.Warrior:
+ case PlayerClass.Knight:
{
const string W = "res://Assets/Characters/warrior/";
return new ClassSpritePaths
@@ -233,6 +433,8 @@ public static ClassSpritePaths GetSpritePaths(PlayerClass cls)
FireballAttackPaths = null,
HitFrame = W + "hit.png",
DeadFrame = W + "skill_down.png", // knocked-down sprite
+ DefenseFrame = W + "defense.png",
+ SkillAttackFrame = W + "skill_attack.png",
};
}
@@ -271,7 +473,7 @@ public static string Serialize(PlayerClass cls)
{
case PlayerClass.Mage: return "mage";
case PlayerClass.Archer: return "archer";
- case PlayerClass.Warrior: return "warrior";
+ case PlayerClass.Knight: return "knight";
default: return "adventurer";
}
}
@@ -286,7 +488,8 @@ public static PlayerClass Deserialize(string value)
{
case "mage": return PlayerClass.Mage;
case "archer": return PlayerClass.Archer;
- case "warrior": return PlayerClass.Warrior;
+ case "knight":
+ case "warrior": return PlayerClass.Knight;
default: return PlayerClass.Adventurer;
}
}
diff --git a/Scripts/Core/Data/Skills/BasicAttackSkill.cs b/Scripts/Core/Data/Skills/BasicAttackSkill.cs
index 1e2edbb..5ef6bbc 100644
--- a/Scripts/Core/Data/Skills/BasicAttackSkill.cs
+++ b/Scripts/Core/Data/Skills/BasicAttackSkill.cs
@@ -2,6 +2,7 @@
using QuestFantasy.Characters;
using QuestFantasy.Core.Base;
+using QuestFantasy.Core.Data;
using QuestFantasy.Core.Data.Attributes;
using QuestFantasy.Core.Systems.StatusEffects;
@@ -50,6 +51,29 @@ public override void Effect(Player player, Character target)
return;
}
+ // Determine aim direction
+ Vector2 targetPos = target != null ? target.GlobalPosition : player.GetGlobalMousePosition();
+ Vector2 towardTarget = targetPos - player.GlobalPosition;
+ Vector2 dir = towardTarget.LengthSquared() > 0.0001f ? towardTarget.Normalized() : Vector2.Right;
+ Vector2 effectPos = player.GlobalPosition + dir * 30f;
+ effectPos.y -= 8f;
+
+ // Spawn standard slash effect only for Knight (Sword Slash)
+ if (player.PlayerClass == PlayerClass.Knight)
+ {
+ SkillProjectileSpawner.SpawnTemporaryVisualEffect(
+ player,
+ "res://Assets/SkillAnimation/slash-effect.png",
+ effectPos,
+ 0.25f,
+ 0.25f,
+ 0.3f,
+ 0f,
+ false,
+ dir.Angle()
+ );
+ }
+
// Allow empty swing if no target
if (target == null)
{
diff --git a/Scripts/Core/Data/Skills/DefenseStanceSkill.cs b/Scripts/Core/Data/Skills/DefenseStanceSkill.cs
new file mode 100644
index 0000000..0c3a497
--- /dev/null
+++ b/Scripts/Core/Data/Skills/DefenseStanceSkill.cs
@@ -0,0 +1,27 @@
+using Godot;
+
+using QuestFantasy.Characters;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ public class DefenseStanceSkill : Attributes.Skills
+ {
+ public DefenseStanceSkill()
+ {
+ Name = "Defense Stance";
+ Description = "Become invulnerable for 3 seconds. Any attack received triggers a counter-attack.";
+ }
+
+ public override float MaxRange => 0f;
+
+ public override float GetCooldownDuration() => 8.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null) return;
+
+ // Activate defense stance for 3 seconds
+ player.ActivateDefenseStance(3.0f);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/DigitArrowSkill.cs b/Scripts/Core/Data/Skills/DigitArrowSkill.cs
new file mode 100644
index 0000000..dadafde
--- /dev/null
+++ b/Scripts/Core/Data/Skills/DigitArrowSkill.cs
@@ -0,0 +1,35 @@
+using Godot;
+
+using QuestFantasy.Characters;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Digit arrow skill for the Archer.
+ /// Fires an arrow that passes through obstacles and enemies,
+ /// traveling a set distance without being blocked.
+ ///
+ public class DigitArrowSkill : Attributes.Skills
+ {
+ public DigitArrowSkill()
+ {
+ Name = "Digit Arrow";
+ Description = "Fire an arrow that passes through obstacles and enemies, traveling a set distance.";
+ }
+
+ public override float MaxRange => 400f;
+
+ public override float GetCooldownDuration() => 1.2f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ // Spawn a digit arrow that passes through obstacles
+ SkillProjectileSpawner.SpawnDigitArrow(player, target, MaxRange);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/FlyingSwordSkill.cs b/Scripts/Core/Data/Skills/FlyingSwordSkill.cs
new file mode 100644
index 0000000..2a52dd8
--- /dev/null
+++ b/Scripts/Core/Data/Skills/FlyingSwordSkill.cs
@@ -0,0 +1,38 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Systems.StatusEffects;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// A returning projectile skill for the Warrior.
+ /// Throws a sword that pierces through enemies, reaches max range,
+ /// and then flies back to the player, damaging enemies again.
+ ///
+ public class FlyingSwordSkill : Attributes.Skills
+ {
+ public FlyingSwordSkill()
+ {
+ Name = "Flying Sword";
+ Description = "Throw a sword that pierces enemies and returns to you.";
+ }
+
+ public override float MaxRange => 250f;
+
+ public override float GetCooldownDuration() => 1.5f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ SkillProjectileSpawner.SpawnFlyingSword(
+ player,
+ target,
+ MaxRange);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/GiantFireballSkill.cs b/Scripts/Core/Data/Skills/GiantFireballSkill.cs
new file mode 100644
index 0000000..0199ac0
--- /dev/null
+++ b/Scripts/Core/Data/Skills/GiantFireballSkill.cs
@@ -0,0 +1,42 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Systems.StatusEffects;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// A massive fireball projectile exclusive to the Mage.
+ /// Explodes on impact with a large AOE and high damage.
+ /// Has a higher chance to apply Burn on each enemy hit.
+ ///
+ public class GiantFireballSkill : Attributes.Skills
+ {
+ public GiantFireballSkill()
+ {
+ Name = "Giant Fireball";
+ Description = "Launch a massive fireball that explodes on impact.";
+ }
+
+ public override float MaxRange => 400f;
+
+ public override float GetCooldownDuration() => 3.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ SkillProjectileSpawner.SpawnGiantFireball(
+ player,
+ target,
+ MaxRange,
+ onHitEffect: () => new BurnEffect(
+ GameConstants.FIREBALL_BURN_DURATION * 1.5f,
+ GameConstants.FIREBALL_BURN_DPS * 2),
+ onHitChance: GameConstants.FIREBALL_BURN_CHANCE * 1.5f);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/IceSpearSkill.cs b/Scripts/Core/Data/Skills/IceSpearSkill.cs
new file mode 100644
index 0000000..0d9bf7a
--- /dev/null
+++ b/Scripts/Core/Data/Skills/IceSpearSkill.cs
@@ -0,0 +1,40 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Systems.StatusEffects;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Ice spear skill for the Mage.
+ /// Launches a spear of ice that pierces and freezes enemies.
+ ///
+ public class IceSpearSkill : Attributes.Skills
+ {
+ public IceSpearSkill()
+ {
+ Name = "Ice Spear";
+ Description = "Launch a spear of ice that pierces and freezes enemies.";
+ }
+
+ public override float MaxRange => 280f;
+
+ public override float GetCooldownDuration() => 1.8f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ // Spawn an ice spear projectile
+ SkillProjectileSpawner.SpawnIceSpear(
+ player,
+ target,
+ MaxRange,
+ onHitEffect: () => new FreezeEffect(1.0f), // 1 second freeze
+ onHitChance: 1.0f); // Always freeze
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/KnightExploseSkill.cs b/Scripts/Core/Data/Skills/KnightExploseSkill.cs
new file mode 100644
index 0000000..78c6b10
--- /dev/null
+++ b/Scripts/Core/Data/Skills/KnightExploseSkill.cs
@@ -0,0 +1,35 @@
+using Godot;
+
+using QuestFantasy.Characters;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Knight explosion skill for the Warrior.
+ /// Creates an expanding explosion at the player's location that continuously
+ /// damages enemies within its expanding radius.
+ ///
+ public class KnightExploseSkill : Attributes.Skills
+ {
+ public KnightExploseSkill()
+ {
+ Name = "Knight Explosion";
+ Description = "Create an expanding explosion at your location that damages enemies inside it repeatedly.";
+ }
+
+ public override float MaxRange => 0f; // Centered on player
+
+ public override float GetCooldownDuration() => 30.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ // Spawn knight explosion at player location
+ SkillProjectileSpawner.SpawnKnightExplosion(player);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/MagicSlashSkill.cs b/Scripts/Core/Data/Skills/MagicSlashSkill.cs
new file mode 100644
index 0000000..fed8967
--- /dev/null
+++ b/Scripts/Core/Data/Skills/MagicSlashSkill.cs
@@ -0,0 +1,83 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Base;
+using QuestFantasy.Core.Data.Attributes;
+using QuestFantasy.Core.Systems.StatusEffects;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Magic slash skill for the Mage.
+ /// Strikes with arcane energy, hitting nearby enemies.
+ ///
+ public class MagicSlashSkill : Attributes.Skills
+ {
+ private const float DAMAGE_VARIANCE_MIN = 0.9f;
+ private const float DAMAGE_VARIANCE_MAX = 1.1f;
+ private const float DAMAGE_REDUCTION_FACTOR = 0.5f;
+
+ public MagicSlashSkill()
+ {
+ Name = "Magic Slash";
+ Description = "Strike with arcane energy, cutting through magic.";
+ }
+
+ public override float MaxRange => 100f;
+
+ public override float GetCooldownDuration() => 1.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ // Determine aim direction (mouse position or target position)
+ Vector2 targetPos = target != null ? target.GlobalPosition : player.GetGlobalMousePosition();
+ Vector2 towardTarget = targetPos - player.GlobalPosition;
+ Vector2 dir = towardTarget.LengthSquared() > 0.0001f ? towardTarget.Normalized() : Vector2.Right;
+
+ // Spawn visual slash effect 36 pixels in front of the player (offset vertically slightly)
+ Vector2 effectPos = player.GlobalPosition + dir * 36f;
+ effectPos.y -= 8f;
+
+ // Spawn visual slash effect, rotated to face aim direction
+ SkillProjectileSpawner.SpawnTemporaryVisualEffect(
+ player,
+ "res://Assets/SkillAnimation/magic_slash-2.png",
+ effectPos,
+ 0.3f,
+ 0.3f,
+ 0.35f,
+ 0f,
+ false,
+ dir.Angle()
+ );
+
+ // Get attacker stats
+ int attackerAtk = player.Attributes?.EffectiveAtk ?? 1;
+ int defenderDef = 0;
+
+ // Damage to each enemy
+ float variance = GD.Randf() * (DAMAGE_VARIANCE_MAX - DAMAGE_VARIANCE_MIN) + DAMAGE_VARIANCE_MIN;
+
+ // If we have a specific target, damage them more
+ if (target != null)
+ {
+ defenderDef = target.Attributes?.EffectiveDef ?? 0;
+ int baseDamage = Mathf.Max(1, attackerAtk - Mathf.FloorToInt(defenderDef * DAMAGE_REDUCTION_FACTOR));
+ int finalDamage = Mathf.RoundToInt(baseDamage * variance);
+
+ target.TakeDamage(finalDamage);
+ GD.Print($"[COMBAT] {player.EntityName} casts Magic Slash on {target.EntityName} for {finalDamage} damage!");
+ }
+ else
+ {
+ // Empty cast
+ GD.Print("[MagicSlashSkill] Empty cast - no targets in range");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/RicochetArrowSkill.cs b/Scripts/Core/Data/Skills/RicochetArrowSkill.cs
new file mode 100644
index 0000000..e7a16e8
--- /dev/null
+++ b/Scripts/Core/Data/Skills/RicochetArrowSkill.cs
@@ -0,0 +1,38 @@
+using Godot;
+
+using QuestFantasy.Characters;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Advanced ricochet arrow projectile for the Archer.
+ /// Fires a single arrow that bounces between walls and enemies up to 4 times.
+ ///
+ public class RicochetArrowSkill : Attributes.Skills
+ {
+ public RicochetArrowSkill()
+ {
+ Name = "Ricochet Arrow";
+ Description = "Fire an arrow that bounces between walls and enemies.";
+ }
+
+ public override float MaxRange => 300f;
+
+ public override float GetCooldownDuration() => 2.5f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ // Spawn an arrow with 4 bounces
+ SkillProjectileSpawner.SpawnRicochetArrow(
+ player,
+ target,
+ MaxRange,
+ bounces: 4);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/RoundhouseSlashSkill.cs b/Scripts/Core/Data/Skills/RoundhouseSlashSkill.cs
new file mode 100644
index 0000000..5f10fdc
--- /dev/null
+++ b/Scripts/Core/Data/Skills/RoundhouseSlashSkill.cs
@@ -0,0 +1,92 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Base;
+using QuestFantasy.Core.Data.Attributes;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Roundhouse slash skill for the Warrior.
+ /// A spinning attack that hits all nearby enemies.
+ ///
+ public class RoundhouseSlashSkill : Attributes.Skills
+ {
+ private const float DAMAGE_VARIANCE_MIN = 0.85f;
+ private const float DAMAGE_VARIANCE_MAX = 1.15f;
+ private const float DAMAGE_REDUCTION_FACTOR = 0.5f;
+
+ public RoundhouseSlashSkill()
+ {
+ Name = "Roundhouse Slash";
+ Description = "Spin and strike all nearby enemies with a powerful slash.";
+ }
+
+ public override float MaxRange => 80f;
+
+ public override float GetCooldownDuration() => 2.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ int attackerAtk = player.Attributes?.EffectiveAtk ?? 1;
+
+ // Spawn spinning roundhouse slash visual effect
+ SkillProjectileSpawner.SpawnTemporaryVisualEffect(
+ player,
+ "res://Assets/SkillAnimation/roundhouse-slash.png",
+ player.GlobalPosition,
+ 0.3f,
+ 0.4f,
+ 0.5f,
+ 15f
+ );
+
+ // Get all Character objects in the scene tree and damage those in range
+ var root = player.GetTree()?.Root;
+ if (root != null)
+ {
+ var characters = GetAllNodesOfType(root);
+ foreach (var enemy in characters)
+ {
+ if (enemy == null || enemy == player || enemy.Attributes?.HP?.IsAlive != true)
+ {
+ continue;
+ }
+
+ float distance = player.GlobalPosition.DistanceTo(enemy.GlobalPosition);
+ if (distance <= MaxRange)
+ {
+ int defenderDef = enemy.Attributes?.EffectiveDef ?? 0;
+ int baseDamage = Mathf.Max(1, attackerAtk - Mathf.FloorToInt(defenderDef * DAMAGE_REDUCTION_FACTOR));
+ float variance = GD.Randf() * (DAMAGE_VARIANCE_MAX - DAMAGE_VARIANCE_MIN) + DAMAGE_VARIANCE_MIN;
+ int finalDamage = Mathf.RoundToInt(baseDamage * variance);
+
+ enemy.TakeDamage(finalDamage);
+ GD.Print($"[COMBAT] {player.EntityName} performs Roundhouse Slash on {enemy.EntityName} for {finalDamage} damage!");
+ }
+ }
+ }
+ }
+
+ private System.Collections.Generic.List GetAllNodesOfType(Node root) where T : Node
+ {
+ var result = new System.Collections.Generic.List();
+ if (root is T tNode)
+ {
+ result.Add(tNode);
+ }
+
+ foreach (Node child in root.GetChildren())
+ {
+ result.AddRange(GetAllNodesOfType(child));
+ }
+
+ return result;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/SkillProjectileSpawner.cs b/Scripts/Core/Data/Skills/SkillProjectileSpawner.cs
index 311c33d..5fc155d 100644
--- a/Scripts/Core/Data/Skills/SkillProjectileSpawner.cs
+++ b/Scripts/Core/Data/Skills/SkillProjectileSpawner.cs
@@ -18,10 +18,35 @@ public static void SpawnArrow(
float onHitChance = 0f)
{
Vector2 direction = ResolveDirection(player, target);
- var node = SkillProjectileNode.CreateArrow(player, direction, maxRange, onHitEffect, onHitChance);
+ var node = SkillProjectileNode.CreateArrow(player, direction, maxRange, 0, onHitEffect, onHitChance);
AttachToScene(player, node);
}
+ public static void SpawnTripleArrow(
+ Player player,
+ Character target,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+
+ // Calculate a perpendicular offset vector (10 pixels wide) to separate the arrows
+ Vector2 perpOffset = new Vector2(-direction.y, direction.x) * 12f;
+
+ var nodeCenter = SkillProjectileNode.CreateArrow(player, direction, maxRange, 0, onHitEffect, onHitChance);
+ var nodeLeft = SkillProjectileNode.CreateArrow(player, direction, maxRange, 0, onHitEffect, onHitChance);
+ var nodeRight = SkillProjectileNode.CreateArrow(player, direction, maxRange, 0, onHitEffect, onHitChance);
+
+ AttachToScene(player, nodeCenter);
+ AttachToScene(player, nodeLeft);
+ AttachToScene(player, nodeRight);
+
+ // Apply the visual offsets
+ nodeLeft.GlobalPosition += perpOffset;
+ nodeRight.GlobalPosition -= perpOffset;
+ }
+
public static void SpawnFireball(
Player player,
Character target,
@@ -34,6 +59,134 @@ public static void SpawnFireball(
AttachToScene(player, node);
}
+ public static void SpawnTripleFireball(
+ Player player,
+ Character target,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+
+ // Calculate spread directions (±15 degrees = ±0.261799 radians)
+ float spreadAngle = 15f * Mathf.Pi / 180f;
+ Vector2 dirLeft = direction.Rotated(-spreadAngle);
+ Vector2 dirRight = direction.Rotated(spreadAngle);
+
+ var nodeCenter = SkillProjectileNode.CreateFireball(player, direction, maxRange, onHitEffect, onHitChance);
+ var nodeLeft = SkillProjectileNode.CreateFireball(player, dirLeft, maxRange, onHitEffect, onHitChance);
+ var nodeRight = SkillProjectileNode.CreateFireball(player, dirRight, maxRange, onHitEffect, onHitChance);
+
+ AttachToScene(player, nodeCenter);
+ AttachToScene(player, nodeLeft);
+ AttachToScene(player, nodeRight);
+ }
+
+ public static void SpawnGiantFireball(
+ Player player,
+ Character target,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateGiantFireball(player, direction, maxRange, onHitEffect, onHitChance);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnFlyingSword(
+ Player player,
+ Character target,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateFlyingSword(player, direction, maxRange, onHitEffect, onHitChance);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnRicochetArrow(
+ Player player,
+ Character target,
+ float maxRange,
+ int bounces,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateArrow(player, direction, maxRange, bounces, onHitEffect, onHitChance);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnKnightExplosion(Player player)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ var node = new KnightExplosionNode(player);
+ AttachToScene(player, node);
+ node.GlobalPosition = player.GlobalPosition;
+
+ GD.Print($"[KnightExplosion] Spawned at {player.GlobalPosition}");
+ }
+
+ public static void SpawnDigitArrow(Player player, Character target, float maxRange)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateDigitArrow(player, direction, maxRange);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnSuperArrow(
+ Player player,
+ Character target,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateSuperArrow(player, direction, maxRange, onHitEffect, onHitChance);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnIceSpear(
+ Player player,
+ Character target,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateIceSpear(player, direction, maxRange, onHitEffect, onHitChance);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnMagicSlash(Player player, Character target, float maxRange)
+ {
+ Vector2 direction = ResolveDirection(player, target);
+ var node = SkillProjectileNode.CreateMagicSlash(player, direction, maxRange);
+ AttachToScene(player, node);
+ }
+
+ public static void SpawnTemporaryVisualEffect(
+ Player player,
+ string texturePath,
+ Vector2 position,
+ float duration,
+ float initialScale,
+ float targetScale = -1f,
+ float rotationSpeed = 0f,
+ bool flipH = false,
+ float initialRotation = 0f)
+ {
+ var node = new TemporaryVisualEffectNode(texturePath, duration, initialScale, targetScale, rotationSpeed, flipH, initialRotation);
+ AttachToScene(player, node);
+ node.GlobalPosition = position;
+ }
+
private static Vector2 ResolveDirection(Player player, Character target)
{
if (target != null && Godot.Object.IsInstanceValid(target))
@@ -54,7 +207,7 @@ private static Vector2 ResolveDirection(Player player, Character target)
return Vector2.Right;
}
- private static void AttachToScene(Player player, SkillProjectileNode node)
+ private static void AttachToScene(Player player, Node2D node)
{
if (player == null || !Godot.Object.IsInstanceValid(player))
{
@@ -115,14 +268,27 @@ internal class SkillProjectileNode : Node2D
private int _flightFrameIndex = -1;
private float _flightFrameDuration = 0f;
private float _impactFrameDuration = ArrowImpactFrameDuration;
+ private int _bouncesRemaining = 0;
+ private readonly float _bounceRange = 150f;
+ private bool _isPiercing = false;
+ private bool _isReturning = false;
+ private bool _returningPhase = false;
+ private bool _ignoreWalls = false;
+ private bool _loopFlightAnimation = true;
+ private Character _lastHitTarget = null;
public static SkillProjectileNode CreateArrow(
Player owner,
Vector2 direction,
float maxRange,
+ int bounces = 0,
Func onHitEffect = null,
float onHitChance = 0f)
{
+ string arrowTexturePath = bounces > 0
+ ? "res://Assets/SkillAnimation/arrow_ricochet.png"
+ : "res://Assets/SkillAnimation/arrow.png";
+
return new SkillProjectileNode
{
_owner = owner,
@@ -135,18 +301,19 @@ public static SkillProjectileNode CreateArrow(
_damageMax = 4,
_isAoe = false,
_aoeRadius = 0f,
+ _bouncesRemaining = bounces,
_onHitEffect = onHitEffect,
_onHitChance = onHitChance,
- _projectileTexture = GD.Load("res://Assets/SkillAnimation/arrow.png"),
+ _projectileTexture = GD.Load(arrowTexturePath),
_projectileScale = ArrowProjectileScale,
_impactScale = ArrowImpactScale,
_flightFrames = new[]
{
- GD.Load("res://Assets/SkillAnimation/arrow.png"),
+ GD.Load(arrowTexturePath),
},
_impactFrames = new[]
{
- GD.Load("res://Assets/SkillAnimation/arrow.png"),
+ GD.Load(arrowTexturePath),
}
};
}
@@ -193,6 +360,231 @@ public static SkillProjectileNode CreateFireball(
};
}
+ public static SkillProjectileNode CreateGiantFireball(
+ Player owner,
+ Vector2 direction,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ return new SkillProjectileNode
+ {
+ _owner = owner,
+ _map = FindMap(owner),
+ _direction = direction,
+ _speed = FireballSpeed * 0.8f, // Slightly slower because it's massive
+ _maxDistance = Mathf.Max(10f, maxRange),
+ _hitRadius = 24f,
+ _damageMin = 10,
+ _damageMax = 18,
+ _isAoe = true,
+ _aoeRadius = 80f,
+ _onHitEffect = onHitEffect,
+ _onHitChance = onHitChance,
+ _projectileTexture = GD.Load("res://Assets/SkillAnimation/fireball.png"),
+ _projectileScale = FireballProjectileScale * 2f,
+ _impactScale = FireballImpactScale * 2f,
+ _flightFrameDuration = FireballFlightFrameDuration,
+ _impactFrameDuration = FireballImpactFrameDuration,
+ _flightFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/fireball1.png"),
+ GD.Load("res://Assets/SkillAnimation/fireball2.png"),
+ GD.Load("res://Assets/SkillAnimation/fireball3.png"),
+ },
+ _impactFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/fireball_hit.png"),
+ GD.Load("res://Assets/SkillAnimation/fireball_hit1.png"),
+ GD.Load("res://Assets/SkillAnimation/fireball_hit2.png"),
+ GD.Load("res://Assets/SkillAnimation/fireball_hit3.png"),
+ }
+ };
+ }
+
+ public static SkillProjectileNode CreateFlyingSword(
+ Player owner,
+ Vector2 direction,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ return new SkillProjectileNode
+ {
+ _owner = owner,
+ _map = FindMap(owner),
+ _direction = direction,
+ _speed = ArrowSpeed * 0.9f,
+ _maxDistance = Mathf.Max(10f, maxRange),
+ _hitRadius = 16f,
+ _damageMin = 6,
+ _damageMax = 12,
+ _isAoe = false,
+ _aoeRadius = 0f,
+ _isPiercing = true,
+ _isReturning = true,
+ _onHitEffect = onHitEffect,
+ _onHitChance = onHitChance,
+ _projectileTexture = GD.Load("res://Assets/SkillAnimation/fly_sword.png"),
+ _projectileScale = 0.3f,
+ _impactScale = 0.3f,
+ _flightFrameDuration = 0.08f,
+ _impactFrameDuration = ArrowImpactFrameDuration,
+ _flightFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/fly_sword.png"),
+ GD.Load("res://Assets/SkillAnimation/fly_sword-1.png"),
+ GD.Load("res://Assets/SkillAnimation/fly_sword-2.png"),
+ },
+ _impactFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/fly_sword.png"),
+ }
+ };
+ }
+
+ public static SkillProjectileNode CreateDigitArrow(
+ Player owner,
+ Vector2 direction,
+ float maxRange)
+ {
+ return new SkillProjectileNode
+ {
+ _owner = owner,
+ _map = FindMap(owner),
+ _direction = direction,
+ _speed = ArrowSpeed * 1.3f,
+ _maxDistance = Mathf.Max(10f, maxRange),
+ _hitRadius = 12f,
+ _damageMin = 4,
+ _damageMax = 7,
+ _isAoe = false,
+ _isPiercing = true,
+ _ignoreWalls = true,
+ _projectileTexture = GD.Load("res://Assets/SkillAnimation/digit_arrow.png"),
+ _projectileScale = 0.25f,
+ _impactScale = 0.25f,
+ _flightFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/digit_arrow.png"),
+ },
+ _impactFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/digit_arrow.png"),
+ }
+ };
+ }
+
+ public static SkillProjectileNode CreateSuperArrow(
+ Player owner,
+ Vector2 direction,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ return new SkillProjectileNode
+ {
+ _owner = owner,
+ _map = FindMap(owner),
+ _direction = direction,
+ _speed = ArrowSpeed * 0.75f,
+ _maxDistance = Mathf.Max(10f, maxRange),
+ _hitRadius = 28f,
+ _damageMin = 8,
+ _damageMax = 15,
+ _isAoe = false,
+ _ignoreWalls = true,
+ _isPiercing = true,
+ _onHitEffect = onHitEffect,
+ _onHitChance = onHitChance,
+ _projectileTexture = GD.Load("res://Assets/SkillAnimation/super_arrow.png"),
+ _projectileScale = 0.55f,
+ _impactScale = 0.55f,
+ _flightFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/super_arrow.png"),
+ },
+ _impactFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/super_arrow.png"),
+ }
+ };
+ }
+
+ public static SkillProjectileNode CreateIceSpear(
+ Player owner,
+ Vector2 direction,
+ float maxRange,
+ Func onHitEffect = null,
+ float onHitChance = 0f)
+ {
+ return new SkillProjectileNode
+ {
+ _owner = owner,
+ _map = FindMap(owner),
+ _direction = direction,
+ _speed = ArrowSpeed * 0.6f,
+ _maxDistance = Mathf.Max(10f, maxRange),
+ _hitRadius = 15f,
+ _damageMin = 5,
+ _damageMax = 9,
+ _isAoe = false,
+ _onHitEffect = onHitEffect,
+ _onHitChance = onHitChance,
+ _projectileTexture = GD.Load("res://Assets/SkillAnimation/ice_spear-1.png"),
+ _projectileScale = 0.3f,
+ _impactScale = 0.35f,
+ _flightFrameDuration = 0.1f,
+ _impactFrameDuration = 0.08f,
+ _loopFlightAnimation = false,
+ _flightFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/ice_spear-1.png"),
+ GD.Load("res://Assets/SkillAnimation/ice_spear-2.png"),
+ GD.Load("res://Assets/SkillAnimation/ice_spear-3.png"),
+ GD.Load("res://Assets/SkillAnimation/ice_spear-4.png"),
+ },
+ _impactFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/ice_spear-hit.png"),
+ }
+ };
+ }
+
+ public static SkillProjectileNode CreateMagicSlash(
+ Player owner,
+ Vector2 direction,
+ float maxRange)
+ {
+ return new SkillProjectileNode
+ {
+ _owner = owner,
+ _map = FindMap(owner),
+ _direction = direction,
+ _speed = ArrowSpeed * 1.2f,
+ _maxDistance = Mathf.Max(10f, maxRange),
+ _hitRadius = 20f,
+ _damageMin = 4,
+ _damageMax = 8,
+ _isAoe = false,
+ _onHitEffect = null,
+ _onHitChance = 0f,
+ _projectileTexture = GD.Load("res://Assets/SkillAnimation/magic_slash-2.png"),
+ _projectileScale = 0.3f,
+ _impactScale = 0.3f,
+ _flightFrameDuration = 0f,
+ _impactFrameDuration = ArrowImpactFrameDuration,
+ _flightFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/magic_slash-2.png"),
+ },
+ _impactFrames = new[]
+ {
+ GD.Load("res://Assets/SkillAnimation/magic_slash-2.png"),
+ }
+ };
+ }
+
public override void _Ready()
{
Texture firstFlightTexture = _projectileTexture;
@@ -234,30 +626,116 @@ public override void _Process(float delta)
UpdateFlightAnimation(delta);
+ if (_returningPhase)
+ {
+ Vector2 toOwner = _owner.GlobalPosition - GlobalPosition;
+ if (toOwner.LengthSquared() < 400f) // approx 20 units
+ {
+ QueueFree();
+ return;
+ }
+ _direction = toOwner.Normalized();
+ Rotation = _direction.Angle();
+ }
+
Vector2 step = _direction * _speed * delta;
Vector2 nextPosition = GlobalPosition + step;
- if (IsBlockedByWall(nextPosition))
+ if (IsBlockedByWall(nextPosition) && !_returningPhase)
{
- BeginImpact(nextPosition, null);
- return;
+ if (_ignoreWalls)
+ {
+ // Do not block - pierce the wall/obstacle!
+ }
+ else if (_bouncesRemaining > 0)
+ {
+ _bouncesRemaining--;
+
+ // Simple reflection based on axis
+ bool xBlocked = IsBlockedByWall(GlobalPosition + new Vector2(step.x, 0));
+ bool yBlocked = IsBlockedByWall(GlobalPosition + new Vector2(0, step.y));
+
+ if (xBlocked) _direction.x *= -1;
+ if (yBlocked) _direction.y *= -1;
+
+ // Corner case: if both or neither (diagonal corner), just flip both
+ if (!xBlocked && !yBlocked) { _direction = -_direction; }
+
+ _direction = _direction.Normalized();
+ Rotation = _direction.Angle();
+ return; // continue next frame
+ }
+ else
+ {
+ if (_isReturning)
+ {
+ StartReturningPhase();
+ return;
+ }
+ BeginImpact(nextPosition, null);
+ return;
+ }
}
Character hitTarget = FindHitTarget(nextPosition);
if (hitTarget != null)
{
- BeginImpact(hitTarget.GlobalPosition, hitTarget);
- return;
+ if (_bouncesRemaining > 0)
+ {
+ ApplyDamage(hitTarget);
+ _bouncesRemaining--;
+
+ // Find next target
+ Character nextTarget = FindNextBounceTarget(hitTarget.GlobalPosition);
+ if (nextTarget != null)
+ {
+ _direction = (nextTarget.GlobalPosition - GlobalPosition).Normalized();
+ Rotation = _direction.Angle();
+ return; // continue next frame
+ }
+ else
+ {
+ // No target to bounce to, just continue flying in current direction so it can bounce off walls later!
+ return;
+ }
+ }
+ else if (_isPiercing)
+ {
+ ApplyDamage(hitTarget);
+ }
+ else
+ {
+ BeginImpact(hitTarget.GlobalPosition, hitTarget);
+ return;
+ }
}
GlobalPosition = nextPosition;
- _traveled += step.Length();
- if (_traveled >= _maxDistance)
+
+ if (!_returningPhase)
{
- QueueFree();
+ _traveled += step.Length();
+ if (_traveled >= _maxDistance)
+ {
+ if (_isReturning)
+ {
+ StartReturningPhase();
+ }
+ else
+ {
+ QueueFree();
+ }
+ }
}
}
+ private void StartReturningPhase()
+ {
+ _returningPhase = true;
+ _damagedTargets.Clear();
+ _isPiercing = true;
+ }
+
private bool IsBlockedByWall(Vector2 position)
{
if (_map == null || !Godot.Object.IsInstanceValid(_map))
@@ -283,6 +761,22 @@ private Character FindHitTarget(Vector2 position)
continue;
}
+ // For bouncing projectiles (ricochet arrow), we only prevent hitting the immediate last hit target consecutively
+ if (_bouncesRemaining > 0)
+ {
+ if (enemy == _lastHitTarget)
+ {
+ continue;
+ }
+ }
+ else
+ {
+ if (_damagedTargets.Contains(enemy))
+ {
+ continue;
+ }
+ }
+
if (enemy.GlobalPosition.DistanceTo(position) <= _hitRadius)
{
return enemy;
@@ -292,6 +786,40 @@ private Character FindHitTarget(Vector2 position)
return null;
}
+ private Character FindNextBounceTarget(Vector2 currentPosition)
+ {
+ if (!IsOwnerAlive())
+ {
+ return null;
+ }
+
+ Character bestTarget = null;
+ float closestDistSq = _bounceRange * _bounceRange;
+
+ foreach (Character enemy in EnumerateEnemyCharacters())
+ {
+ if (enemy?.Attributes?.HP == null || !enemy.Attributes.HP.IsAlive)
+ {
+ continue;
+ }
+
+ // Do not bounce back to the immediate last hit target consecutively
+ if (enemy == _lastHitTarget)
+ {
+ continue;
+ }
+
+ float distSq = enemy.GlobalPosition.DistanceSquaredTo(currentPosition);
+ if (distSq <= closestDistSq)
+ {
+ closestDistSq = distSq;
+ bestTarget = enemy;
+ }
+ }
+
+ return bestTarget;
+ }
+
private void BeginImpact(Vector2 impactPosition, Character directHitTarget)
{
GlobalPosition = impactPosition;
@@ -331,7 +859,22 @@ private void UpdateFlightAnimation(float delta)
}
_flightFrameTimer = 0f;
- _flightFrameIndex = (_flightFrameIndex + 1) % _flightFrames.Length;
+ int nextIndex = _flightFrameIndex + 1;
+ if (nextIndex >= _flightFrames.Length)
+ {
+ if (_loopFlightAnimation)
+ {
+ _flightFrameIndex = 0;
+ }
+ else
+ {
+ _flightFrameIndex = _flightFrames.Length - 1; // Hold on the last frame
+ }
+ }
+ else
+ {
+ _flightFrameIndex = nextIndex;
+ }
_sprite.Texture = _flightFrames[_flightFrameIndex] ?? _sprite.Texture;
}
@@ -386,9 +929,20 @@ private void ApplyDamage(Character target)
return;
}
- if (_damagedTargets.Contains(target))
+ // For bouncing projectiles, allow re-damaging targets on subsequent bounces, but not consecutively
+ if (_bouncesRemaining > 0)
{
- return;
+ if (target == _lastHitTarget)
+ {
+ return;
+ }
+ }
+ else
+ {
+ if (_damagedTargets.Contains(target))
+ {
+ return;
+ }
}
// Use effective stats so Burn (ATK debuff) and Bleed (DEF debuff) are reflected
@@ -399,6 +953,7 @@ private void ApplyDamage(Character target)
target.TakeDamage(damage);
_damagedTargets.Add(target);
+ _lastHitTarget = target;
GD.Print($"[COMBAT] {_owner.EntityName} hit {target.EntityName} with projectile for {damage}. HP={target.Attributes.HP.CurrentHP}/{target.Attributes.HP.MaxHP}");
@@ -478,4 +1033,196 @@ private bool IsOwnerAlive()
return _owner != null && Godot.Object.IsInstanceValid(_owner);
}
}
+
+ internal class TemporaryVisualEffectNode : Node2D
+ {
+ private readonly string _texturePath;
+ private readonly float _duration;
+ private readonly float _initialScale;
+ private readonly float _targetScale;
+ private readonly float _rotationSpeed;
+ private readonly bool _flipH;
+ private readonly float _initialRotation;
+
+ private Sprite _sprite;
+ private float _elapsed = 0f;
+
+ public TemporaryVisualEffectNode(string texturePath, float duration, float initialScale, float targetScale = -1f, float rotationSpeed = 0f, bool flipH = false, float initialRotation = 0f)
+ {
+ _texturePath = texturePath;
+ _duration = duration;
+ _initialScale = initialScale;
+ _targetScale = targetScale < 0f ? initialScale : targetScale;
+ _rotationSpeed = rotationSpeed;
+ _flipH = flipH;
+ _initialRotation = initialRotation;
+ }
+
+ public override void _Ready()
+ {
+ Texture tex = GD.Load(_texturePath);
+ _sprite = new Sprite
+ {
+ Texture = tex,
+ Centered = true,
+ Scale = new Vector2(_initialScale, _initialScale),
+ FlipH = _flipH
+ };
+ AddChild(_sprite);
+ Rotation = _initialRotation;
+ SetProcess(true);
+ }
+
+ public override void _Process(float delta)
+ {
+ _elapsed += delta;
+ if (_elapsed >= _duration)
+ {
+ QueueFree();
+ return;
+ }
+
+ // Lerp scale
+ float t = _elapsed / _duration;
+ float currentScale = Mathf.Lerp(_initialScale, _targetScale, t);
+ _sprite.Scale = new Vector2(currentScale, currentScale);
+
+ // Lerp opacity (fade out)
+ float opacity = Mathf.Lerp(1f, 0f, t);
+ _sprite.Modulate = new Color(1f, 1f, 1f, opacity);
+
+ // Rotate
+ if (_rotationSpeed != 0f)
+ {
+ Rotation += _rotationSpeed * delta;
+ }
+ }
+ }
+
+ internal class KnightExplosionNode : Node2D
+ {
+ private const float DURATION = 3.0f; // 3 seconds duration
+ private const float INITIAL_SCALE = 0.25f;
+ private const float TARGET_SCALE = 0.5f; // growing to 2x initial scale (0.25 * 2 = 0.5)
+ private const float DAMAGE_TICK_INTERVAL = 0.5f; // damage tick every 0.5s
+ private const float BASE_RADIUS = 160f; // base radius of explosion at scale 1.0
+
+ private readonly Player _owner;
+ private Sprite _sprite;
+ private float _elapsed = 0f;
+ private float _tickTimer = 0f;
+
+ public KnightExplosionNode(Player owner)
+ {
+ _owner = owner;
+ }
+
+ public override void _Ready()
+ {
+ Texture tex = GD.Load("res://Assets/SkillAnimation/knight_explose.png");
+ _sprite = new Sprite
+ {
+ Texture = tex,
+ Centered = true,
+ Scale = new Vector2(INITIAL_SCALE, INITIAL_SCALE)
+ };
+ AddChild(_sprite);
+ SetProcess(true);
+
+ // Deal first tick immediately
+ DealExplosionDamage();
+ }
+
+ public override void _Process(float delta)
+ {
+ if (_owner == null || !Godot.Object.IsInstanceValid(_owner))
+ {
+ QueueFree();
+ return;
+ }
+
+ _elapsed += delta;
+ if (_elapsed >= DURATION)
+ {
+ QueueFree();
+ return;
+ }
+
+ // Lerp scale
+ float t = _elapsed / DURATION;
+ float currentScale = Mathf.Lerp(INITIAL_SCALE, TARGET_SCALE, t);
+ _sprite.Scale = new Vector2(currentScale, currentScale);
+
+ // Lerp opacity (fade out towards the end)
+ float opacity = t > 0.8f ? Mathf.Lerp(1f, 0f, (t - 0.8f) / 0.2f) : 1f;
+ _sprite.Modulate = new Color(1f, 1f, 1f, opacity);
+
+ // Tick damage
+ _tickTimer += delta;
+ if (_tickTimer >= DAMAGE_TICK_INTERVAL)
+ {
+ _tickTimer -= DAMAGE_TICK_INTERVAL;
+ DealExplosionDamage();
+ }
+ }
+
+ private void DealExplosionDamage()
+ {
+ if (_owner == null || !Godot.Object.IsInstanceValid(_owner))
+ {
+ return;
+ }
+
+ float currentScale = _sprite.Scale.x;
+ float currentRadius = BASE_RADIUS * currentScale;
+
+ int attackerAtk = _owner.Attributes?.EffectiveAtk ?? 1;
+
+ foreach (Character enemy in EnumerateEnemyCharacters())
+ {
+ if (enemy?.Attributes?.HP == null || !enemy.Attributes.HP.IsAlive)
+ {
+ continue;
+ }
+
+ if (enemy.GlobalPosition.DistanceTo(GlobalPosition) <= currentRadius)
+ {
+ int defenderDef = enemy.Attributes?.EffectiveDef ?? 0;
+ int baseDamage = Mathf.Max(1, attackerAtk - Mathf.FloorToInt(defenderDef * 0.4f));
+ // Roll smaller random damage per tick
+ int rolled = Mathf.RoundToInt((float)GD.RandRange(1, 3));
+ int damage = Mathf.Max(1, baseDamage + rolled);
+
+ enemy.TakeDamage(damage);
+ GD.Print($"[COMBAT] Knight Explosion ticked on {enemy.EntityName} for {damage} damage!");
+ }
+ }
+ }
+
+ private IEnumerable EnumerateEnemyCharacters()
+ {
+ Node root = _owner?.GetTree()?.Root;
+ if (root == null)
+ {
+ yield break;
+ }
+
+ var stack = new Stack();
+ stack.Push(root);
+
+ while (stack.Count > 0)
+ {
+ Node node = stack.Pop();
+ if (node is Character character && character != _owner)
+ {
+ yield return character;
+ }
+
+ foreach (Node child in node.GetChildren())
+ {
+ stack.Push(child);
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/SuperArrowSkill.cs b/Scripts/Core/Data/Skills/SuperArrowSkill.cs
new file mode 100644
index 0000000..a1d3f50
--- /dev/null
+++ b/Scripts/Core/Data/Skills/SuperArrowSkill.cs
@@ -0,0 +1,41 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Systems.StatusEffects;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Super arrow skill for the Archer.
+ /// A powerful arrow that paralyzes enemies for 5 seconds on hit.
+ /// Long cooldown skill.
+ ///
+ public class SuperArrowSkill : Attributes.Skills
+ {
+ public SuperArrowSkill()
+ {
+ Name = "Super Arrow";
+ Description = "Fire a powerful arrow that paralyzes enemies for 5 seconds.";
+ }
+
+ public override float MaxRange => 350f;
+
+ public override float GetCooldownDuration() => 10.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ // Spawn an arrow that applies stun effect on hit
+ SkillProjectileSpawner.SpawnSuperArrow(
+ player,
+ target,
+ MaxRange,
+ onHitEffect: () => new StunEffect(5.0f), // 5 seconds stun (paralysis)
+ onHitChance: 1.0f); // Always apply
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/TripleArrowSkill.cs b/Scripts/Core/Data/Skills/TripleArrowSkill.cs
new file mode 100644
index 0000000..29e267e
--- /dev/null
+++ b/Scripts/Core/Data/Skills/TripleArrowSkill.cs
@@ -0,0 +1,36 @@
+using Godot;
+
+using QuestFantasy.Characters;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Advanced triple arrow projectile for the Archer.
+ /// Fires 3 arrows simultaneously in the same direction, slightly offset side-by-side.
+ ///
+ public class TripleArrowSkill : Attributes.Skills
+ {
+ public TripleArrowSkill()
+ {
+ Name = "Triple Arrow";
+ Description = "Fire 3 arrows at once that pierce enemies at range.";
+ }
+
+ public override float MaxRange => 300f;
+
+ public override float GetCooldownDuration() => 1.5f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ SkillProjectileSpawner.SpawnTripleArrow(
+ player,
+ target,
+ MaxRange);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Core/Data/Skills/TripleFireballSkill.cs b/Scripts/Core/Data/Skills/TripleFireballSkill.cs
new file mode 100644
index 0000000..6b7a346
--- /dev/null
+++ b/Scripts/Core/Data/Skills/TripleFireballSkill.cs
@@ -0,0 +1,43 @@
+using Godot;
+
+using QuestFantasy.Characters;
+using QuestFantasy.Core.Systems.StatusEffects;
+
+namespace QuestFantasy.Core.Data.Skills
+{
+ ///
+ /// Advanced triple fireball projectile for the Mage.
+ /// Fires 3 fireballs simultaneously in a spread pattern.
+ /// Explodes on wall or monster hit and damages nearby enemies.
+ /// Has a 25% chance to apply Burn on each enemy hit (configurable via GameConstants).
+ ///
+ public class TripleFireballSkill : Attributes.Skills
+ {
+ public TripleFireballSkill()
+ {
+ Name = "Triple Fireball";
+ Description = "Launch 3 fireballs in a spread that explode on impact.";
+ }
+
+ public override float MaxRange => 300f;
+
+ public override float GetCooldownDuration() => 2.0f;
+
+ public override void Effect(Player player, Character target)
+ {
+ if (player == null)
+ {
+ return;
+ }
+
+ SkillProjectileSpawner.SpawnTripleFireball(
+ player,
+ target,
+ MaxRange,
+ onHitEffect: () => new BurnEffect(
+ GameConstants.FIREBALL_BURN_DURATION,
+ GameConstants.FIREBALL_BURN_DPS),
+ onHitChance: GameConstants.FIREBALL_BURN_CHANCE);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Scripts/Prototype/LobbyManager.cs b/Scripts/Prototype/LobbyManager.cs
index 97aebfd..e85d180 100644
--- a/Scripts/Prototype/LobbyManager.cs
+++ b/Scripts/Prototype/LobbyManager.cs
@@ -313,6 +313,20 @@ private void SetupClassSelectUI()
_classSelectUI = new ClassSelectUI();
AddChild(_classSelectUI);
_classSelectUI.ClassSelected += OnClassSelected;
+ _classSelectUI.SkillLoadoutChanged += OnSkillLoadoutChanged;
+ }
+
+ private void OnSkillLoadoutChanged(List orderedSkillIds)
+ {
+ Player target = _player;
+ if (target == null)
+ {
+ return;
+ }
+
+ target.SetEquippedSkills(orderedSkillIds);
+ GD.Print($"[Lobby] Applied skill loadout to {target.Name}");
+ SyncRequested?.Invoke();
}
private void OnNpcClassChangeRequested(NPC npc, Player player)
@@ -325,8 +339,9 @@ private void OnNpcClassChangeRequested(NPC npc, Player player)
Player target = player ?? _player;
PlayerClass current = target?.PlayerClass ?? PlayerClass.Adventurer;
int level = (int)(target?.Level ?? 1);
+ var equippedSkills = target?.GetEquippedSkillIds();
- _classSelectUI.Show(current, level);
+ _classSelectUI.Show(current, level, equippedSkills);
GD.Print($"[Lobby] Class selector opened by {npc.EntityName}. Current class: {current}, Player Level: {level}");
}
diff --git a/Scripts/UI/ClassSelectUI.cs b/Scripts/UI/ClassSelectUI.cs
index a4a0dea..3eb87e0 100644
--- a/Scripts/UI/ClassSelectUI.cs
+++ b/Scripts/UI/ClassSelectUI.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
using Godot;
@@ -6,12 +8,6 @@
namespace QuestFantasy.UI
{
- ///
- /// Fullscreen overlay panel presented when the player interacts with the "Previous Hero" NPC.
- /// Extends CanvasLayer (same as DifficultySelectionUI) so it is always fixed to screen-space,
- /// independent of the game camera position.
- /// Fires when the player confirms a choice.
- ///
public class ClassSelectUI : CanvasLayer
{
// ── Layout constants ──────────────────────────────────────────────
@@ -41,26 +37,41 @@ public class ClassSelectUI : CanvasLayer
private static readonly Color BtnCloseBg = new Color(0.20f, 0.22f, 0.28f, 1f);
private static readonly Color BtnCloseHover = new Color(0.30f, 0.33f, 0.42f, 1f);
+ // Tab colors
+ private static readonly Color TabNormal = new Color(0.15f, 0.18f, 0.25f, 1f);
+ private static readonly Color TabSelected = new Color(0.25f, 0.45f, 0.85f, 1f);
+
// ── State ─────────────────────────────────────────────────────────
private PlayerClass _currentClass = PlayerClass.Adventurer;
private PlayerClass _selectedClass = PlayerClass.Adventurer;
private int _playerLevel = 1;
- // Per-card panel references kept for highlight refresh (no node-path lookup needed)
- private Panel[] _cardPanels;
+ // Skill state
+ private readonly List _equippedSkillIds = new List();
+ private ReadOnlyCollection _availableSkills;
- // Root container — shown/hidden as a whole
+ // UI References
private Control _root;
private Label _subtitleLabel;
+ private Control _classTabContent;
+ private Control _skillTabContent;
+ private Button _tabClassBtn;
+ private Button _tabSkillsBtn;
+ private Panel[] _cardPanels;
+
+ // Skill UI References
+ private Label[] _skillSlotLabels;
+ private Panel[] _skillCardPanels;
public event Action ClassSelected;
+ public event Action
> SkillLoadoutChanged;
private static readonly PlayerClass[] AllClasses =
{
PlayerClass.Adventurer,
PlayerClass.Mage,
PlayerClass.Archer,
- PlayerClass.Warrior
+ PlayerClass.Knight
};
// ── Class accent colours ──────────────────────────────────────────
@@ -70,7 +81,7 @@ private static Color GetClassAccent(PlayerClass cls)
{
case PlayerClass.Mage: return new Color(0.75f, 0.40f, 1.00f, 1f);
case PlayerClass.Archer: return new Color(0.40f, 0.90f, 0.50f, 1f);
- case PlayerClass.Warrior: return new Color(1.00f, 0.50f, 0.25f, 1f);
+ case PlayerClass.Knight: return new Color(1.00f, 0.50f, 0.25f, 1f);
default: return new Color(0.40f, 0.70f, 1.00f, 1f);
}
}
@@ -81,7 +92,7 @@ private static string GetClassEmoji(PlayerClass cls)
{
case PlayerClass.Mage: return "🔮";
case PlayerClass.Archer: return "🏹";
- case PlayerClass.Warrior: return "⚔️";
+ case PlayerClass.Knight: return "⚔️";
default: return "🗺️";
}
}
@@ -90,23 +101,35 @@ private static string GetClassEmoji(PlayerClass cls)
public override void _Ready()
{
- // Keep receiving input even when game tree is paused
PauseMode = PauseModeEnum.Process;
BuildLayout();
_root.Visible = false;
}
- /// Opens the panel, pre-selecting the player's current class.
- public void Show(PlayerClass currentClass, int playerLevel)
+ public void Show(PlayerClass currentClass, int playerLevel, IReadOnlyList currentEquippedSkills = null)
{
_currentClass = currentClass;
_selectedClass = currentClass;
_playerLevel = playerLevel;
+
+ _equippedSkillIds.Clear();
+ if (currentEquippedSkills != null)
+ {
+ _equippedSkillIds.AddRange(currentEquippedSkills);
+ }
+ else
+ {
+ _equippedSkillIds.AddRange(PlayerClassData.GetDefaultSkillLoadout(_currentClass));
+ }
+
+ _availableSkills = PlayerClassData.GetAllSkillDefinitions(_currentClass);
+
if (_subtitleLabel != null)
{
_subtitleLabel.Text = $"Different classes unlock different skills. You can change class again any time. (Level Requirement: Lv.{playerLevel}/{GameConstants.CLASS_CHANGE_MIN_LEVEL})";
}
RefreshCardHighlights();
+ SwitchTab(0);
_root.Visible = true;
}
@@ -122,13 +145,11 @@ public void Show(PlayerClass currentClass, int playerLevel)
private void BuildLayout()
{
- // Full-screen Control root — anchored to entire viewport via preset
_root = new Control();
_root.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
_root.MouseFilter = Control.MouseFilterEnum.Stop;
AddChild(_root);
- // Dim overlay
var overlay = new ColorRect
{
Color = BgOverlay,
@@ -137,10 +158,8 @@ private void BuildLayout()
overlay.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
_root.AddChild(overlay);
- // Centred panel container
var panel = new Panel();
panel.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Center);
- // Offset by half the panel size so it is perfectly centred
panel.MarginLeft = -PanelWidth / 2f;
panel.MarginTop = -PanelHeight / 2f;
panel.MarginRight = PanelWidth / 2f;
@@ -148,24 +167,49 @@ private void BuildLayout()
panel.AddStyleboxOverride("panel", MakePanelStyle());
_root.AddChild(panel);
- // ── Header ──────────────────────────────────────────────────
+ // Tabs
+ var tabsBox = new HBoxContainer();
+ tabsBox.SetAnchorsAndMarginsPreset(Control.LayoutPreset.TopWide);
+ tabsBox.MarginLeft = 24f;
+ tabsBox.MarginRight = -24f;
+ tabsBox.MarginTop = 16f;
+ tabsBox.MarginBottom = 50f;
+ panel.AddChild(tabsBox);
+
+ _tabClassBtn = CreateStyledButton("⚡ Change Class", TabSelected, TabSelected);
+ _tabClassBtn.SizeFlagsHorizontal = (int)Control.SizeFlags.ExpandFill;
+ _tabClassBtn.Connect("pressed", this, nameof(OnTabPressed), new Godot.Collections.Array { 0 });
+ tabsBox.AddChild(_tabClassBtn);
+
+ _tabSkillsBtn = CreateStyledButton("🗡 Skills", TabNormal, TabSelected);
+ _tabSkillsBtn.SizeFlagsHorizontal = (int)Control.SizeFlags.ExpandFill;
+ _tabSkillsBtn.Connect("pressed", this, nameof(OnTabPressed), new Godot.Collections.Array { 1 });
+ tabsBox.AddChild(_tabSkillsBtn);
+
+ _classTabContent = new Control();
+ _classTabContent.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
+ _classTabContent.MarginTop = 50f;
+ panel.AddChild(_classTabContent);
+
+ _skillTabContent = new Control();
+ _skillTabContent.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
+ _skillTabContent.MarginTop = 50f;
+ _skillTabContent.Visible = false;
+ panel.AddChild(_skillTabContent);
+
+ BuildClassTabContent();
+ BuildSkillTabContent();
+ }
+
+ private void BuildClassTabContent()
+ {
var header = new VBoxContainer();
header.SetAnchorsAndMarginsPreset(Control.LayoutPreset.TopWide);
header.MarginLeft = 24f;
header.MarginRight = -24f;
header.MarginTop = 16f;
header.MarginBottom = HeaderHeight;
- panel.AddChild(header);
-
- var title = new Label
- {
- Text = "⚡ Choose Your Class",
- Align = Label.AlignEnum.Center,
- RectMinSize = new Vector2(0f, 34f),
- MouseFilter = Control.MouseFilterEnum.Ignore,
- };
- title.AddColorOverride("font_color", HeaderColor);
- header.AddChild(title);
+ _classTabContent.AddChild(header);
_subtitleLabel = new Label
{
@@ -178,7 +222,6 @@ private void BuildLayout()
_subtitleLabel.AddColorOverride("font_color", SubHeaderColor);
header.AddChild(_subtitleLabel);
- // ── Cards row ───────────────────────────────────────────────
float totalCardsW = AllClasses.Length * CardWidth + (AllClasses.Length - 1) * CardSpacing;
float cardsLeft = (PanelWidth - totalCardsW) / 2f;
float cardsTop = HeaderHeight + 8f;
@@ -190,23 +233,85 @@ private void BuildLayout()
PlayerClass cls = AllClasses[i];
float cardX = cardsLeft + i * (CardWidth + CardSpacing);
var card = BuildClassCard(cls, cardX, cardsTop, i);
- panel.AddChild(card);
+ _classTabContent.AddChild(card);
}
- // ── Footer buttons ───────────────────────────────────────────
- float footerY = PanelHeight - FooterHeight + 4f;
+ float footerY = PanelHeight - FooterHeight - 50f;
- var confirmBtn = CreateStyledButton("✔ Confirm Class", BtnConfirmBg, BtnConfirmHover);
+ var confirmBtn = CreateStyledButton("✔ Confirm Class", BtnConfirmBg, BtnConfirmHover);
confirmBtn.RectPosition = new Vector2(PanelWidth / 2f - 152f, footerY);
confirmBtn.RectMinSize = new Vector2(142f, 42f);
confirmBtn.Connect("pressed", this, nameof(OnConfirmPressed));
- panel.AddChild(confirmBtn);
+ _classTabContent.AddChild(confirmBtn);
- var closeBtn = CreateStyledButton("✖ Cancel", BtnCloseBg, BtnCloseHover);
+ var closeBtn = CreateStyledButton("✖ Cancel", BtnCloseBg, BtnCloseHover);
closeBtn.RectPosition = new Vector2(PanelWidth / 2f + 14f, footerY);
closeBtn.RectMinSize = new Vector2(112f, 42f);
closeBtn.Connect("pressed", this, nameof(OnClosePressed));
- panel.AddChild(closeBtn);
+ _classTabContent.AddChild(closeBtn);
+ }
+
+ private void BuildSkillTabContent()
+ {
+ // Skill slots (Top)
+ var slotsBox = new HBoxContainer();
+ slotsBox.SetAnchorsAndMarginsPreset(Control.LayoutPreset.TopWide);
+ slotsBox.MarginLeft = 50f;
+ slotsBox.MarginRight = -50f;
+ slotsBox.MarginTop = 16f;
+ slotsBox.MarginBottom = 76f;
+ slotsBox.Alignment = BoxContainer.AlignMode.Center;
+ slotsBox.AddConstantOverride("separation", 20);
+ _skillTabContent.AddChild(slotsBox);
+
+ _skillSlotLabels = new Label[3];
+ for (int i = 0; i < 3; i++)
+ {
+ var slotPanel = new Panel();
+ slotPanel.RectMinSize = new Vector2(200f, 60f);
+ slotPanel.AddStyleboxOverride("panel", MakeCardStyle(new Color(0.12f, 0.15f, 0.22f, 1f), new Color(0.3f, 0.4f, 0.6f, 1f), 2));
+
+ var lbl = new Label
+ {
+ Text = $"Slot {i + 1}: Empty",
+ Align = Label.AlignEnum.Center,
+ Valign = Label.VAlign.Center,
+ RectMinSize = new Vector2(200f, 60f)
+ };
+ slotPanel.AddChild(lbl);
+ _skillSlotLabels[i] = lbl;
+ slotsBox.AddChild(slotPanel);
+ }
+
+ // Scroll container for skills (Middle)
+ var scroll = new ScrollContainer();
+ scroll.Name = "ScrollContainer";
+ scroll.SetAnchorsAndMarginsPreset(Control.LayoutPreset.TopWide);
+ scroll.MarginLeft = 24f;
+ scroll.MarginRight = -24f;
+ scroll.MarginTop = 100f;
+ scroll.MarginBottom = 385f;
+ scroll.RectMinSize = new Vector2(0f, 285f);
+ scroll.ScrollVerticalEnabled = false;
+ scroll.ScrollHorizontalEnabled = true;
+ _skillTabContent.AddChild(scroll);
+
+ // Skill grid inside scroll container
+ var gridBox = new HBoxContainer();
+ gridBox.Name = "SkillGrid";
+ gridBox.SizeFlagsHorizontal = (int)Control.SizeFlags.ExpandFill;
+ gridBox.SizeFlagsVertical = (int)Control.SizeFlags.ExpandFill;
+ gridBox.Alignment = BoxContainer.AlignMode.Center;
+ gridBox.AddConstantOverride("separation", (int)CardSpacing);
+ scroll.AddChild(gridBox);
+
+ // Footer (Bottom)
+ float footerY = PanelHeight - FooterHeight - 50f;
+ var saveBtn = CreateStyledButton("✔ Save Loadout", BtnConfirmBg, BtnConfirmHover);
+ saveBtn.RectPosition = new Vector2(PanelWidth / 2f - 71f, footerY);
+ saveBtn.RectMinSize = new Vector2(142f, 42f);
+ saveBtn.Connect("pressed", this, nameof(OnSaveLoadoutPressed));
+ _skillTabContent.AddChild(saveBtn);
}
// ── Card builder ──────────────────────────────────────────────────
@@ -219,30 +324,38 @@ private Panel BuildClassCard(PlayerClass cls, float x, float y, int index)
card.RectPosition = new Vector2(x, y);
card.RectMinSize = new Vector2(CardWidth, CardHeight);
card.AddStyleboxOverride("panel", MakeCardStyle(CardBgNormal, CardBorderNormal));
+ card.MouseFilter = Control.MouseFilterEnum.Stop;
- // Accent stripe at the very top
var stripe = new ColorRect { Color = accent, MouseFilter = Control.MouseFilterEnum.Ignore };
stripe.SetAnchorsAndMarginsPreset(Control.LayoutPreset.TopWide);
stripe.MarginBottom = -(CardHeight - 6f);
card.AddChild(stripe);
- // Content VBox
var vbox = new VBoxContainer();
vbox.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
vbox.MarginLeft = 8f;
vbox.MarginRight = -8f;
vbox.MarginTop = 14f;
vbox.MarginBottom = -8f;
+ vbox.SizeFlagsHorizontal = (int)Control.SizeFlags.ExpandFill;
+ vbox.SizeFlagsVertical = (int)Control.SizeFlags.ExpandFill;
card.AddChild(vbox);
vbox.AddChild(MakeLabel(GetClassEmoji(cls), 32f, CardTitleColor, center: true));
vbox.AddChild(MakeLabel(PlayerClassData.GetDisplayName(cls), 22f, CardTitleColor, center: true));
vbox.AddChild(new HSeparator { RectMinSize = new Vector2(0f, 4f) });
vbox.AddChild(MakeLabel(PlayerClassData.GetDescription(cls), 72f, CardDescColor, center: true, wrap: true));
- vbox.AddChild(MakeLabel("Skills:", 16f, SubHeaderColor, center: true));
- vbox.AddChild(MakeLabel(PlayerClassData.GetSkillListText(cls), 30f, SkillLabelColor, center: true, wrap: true));
- // Transparent click-receiver over the whole card
+ // Display all available skills for this class
+ vbox.AddChild(MakeLabel("Available Skills:", 16f, SubHeaderColor, center: true));
+
+ var skillDefs = PlayerClassData.GetAllSkillDefinitions(cls);
+ foreach (var skillDef in skillDefs)
+ {
+ var skillLbl = MakeLabel($"{skillDef.Emoji} {skillDef.DisplayName}", 12f, SkillLabelColor, center: false, wrap: true);
+ vbox.AddChild(skillLbl);
+ }
+
var btn = new Button { Text = string.Empty, Flat = true };
btn.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
foreach (var s in new[] { "normal", "hover", "pressed", "focus" })
@@ -256,6 +369,37 @@ private Panel BuildClassCard(PlayerClass cls, float x, float y, int index)
return card;
}
+ private Panel BuildSkillCard(SkillDefinition def)
+ {
+ var card = new Panel();
+ card.RectMinSize = new Vector2(CardWidth, CardHeight);
+
+ var vbox = new VBoxContainer();
+ vbox.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
+ vbox.MarginLeft = 8f;
+ vbox.MarginRight = -8f;
+ vbox.MarginTop = 14f;
+ vbox.MarginBottom = -8f;
+ card.AddChild(vbox);
+
+ vbox.AddChild(MakeLabel(def.Emoji, 32f, CardTitleColor, center: true));
+ vbox.AddChild(MakeLabel(def.DisplayName, 22f, CardTitleColor, center: true));
+ vbox.AddChild(new HSeparator { RectMinSize = new Vector2(0f, 4f) });
+ vbox.AddChild(MakeLabel(def.Description, 72f, CardDescColor, center: true, wrap: true));
+ vbox.AddChild(MakeLabel($"CD: {def.CooldownSec}s", 16f, SubHeaderColor, center: true));
+
+ var btn = new Button { Text = string.Empty, Flat = true };
+ btn.SetAnchorsAndMarginsPreset(Control.LayoutPreset.Wide);
+ foreach (var s in new[] { "normal", "hover", "pressed", "focus" })
+ {
+ btn.AddStyleboxOverride(s, new StyleBoxEmpty());
+ }
+ btn.Connect("pressed", this, nameof(OnSkillCardPressed), new Godot.Collections.Array { def.Id });
+ card.AddChild(btn);
+
+ return card;
+ }
+
// ── Style / Label helpers ─────────────────────────────────────────
private static StyleBoxFlat MakePanelStyle()
@@ -335,6 +479,25 @@ private static StyleBoxFlat MakeButtonStyle(Color bg)
// ── Signal handlers ───────────────────────────────────────────────
+ private void SwitchTab(int tabIndex)
+ {
+ _classTabContent.Visible = (tabIndex == 0);
+ _skillTabContent.Visible = (tabIndex == 1);
+
+ _tabClassBtn.AddStyleboxOverride("normal", MakeButtonStyle(tabIndex == 0 ? TabSelected : TabNormal));
+ _tabSkillsBtn.AddStyleboxOverride("normal", MakeButtonStyle(tabIndex == 1 ? TabSelected : TabNormal));
+
+ if (tabIndex == 1)
+ {
+ RefreshSkillTab();
+ }
+ }
+
+ private void OnTabPressed(int tabIndex)
+ {
+ SwitchTab(tabIndex);
+ }
+
private void OnCardPressed(int index)
{
if (index >= 0 && index < AllClasses.Length)
@@ -342,7 +505,6 @@ private void OnCardPressed(int index)
PlayerClass targetClass = AllClasses[index];
if (targetClass != PlayerClass.Adventurer && _playerLevel < GameConstants.CLASS_CHANGE_MIN_LEVEL)
{
- // Block selection of locked advanced classes (only Adventurer is selectable)
return;
}
@@ -352,9 +514,27 @@ private void OnCardPressed(int index)
}
private void OnConfirmPressed()
+ {
+ if (_selectedClass != _currentClass)
+ {
+ // Class changed -> Reset skills to new class defaults
+ _currentClass = _selectedClass;
+ _availableSkills = PlayerClassData.GetAllSkillDefinitions(_currentClass);
+ _equippedSkillIds.Clear();
+ _equippedSkillIds.AddRange(PlayerClassData.GetDefaultSkillLoadout(_currentClass));
+ ClassSelected?.Invoke(_selectedClass);
+ SwitchTab(1); // Auto jump to skills tab
+ }
+ else
+ {
+ Hide();
+ }
+ }
+
+ private void OnSaveLoadoutPressed()
{
Hide();
- ClassSelected?.Invoke(_selectedClass);
+ SkillLoadoutChanged?.Invoke(_equippedSkillIds);
}
private void OnClosePressed()
@@ -362,6 +542,30 @@ private void OnClosePressed()
Hide();
}
+ private void OnSkillCardPressed(string skillId)
+ {
+ // If class only has 1 skill, it's always equipped
+ if (_availableSkills.Count <= 1) return;
+
+ if (_equippedSkillIds.Contains(skillId))
+ {
+ _equippedSkillIds.Remove(skillId);
+ }
+ else
+ {
+ if (_equippedSkillIds.Count >= 3)
+ {
+ // Replace the last slot if full
+ _equippedSkillIds[2] = skillId;
+ }
+ else
+ {
+ _equippedSkillIds.Add(skillId);
+ }
+ }
+ RefreshSkillTab();
+ }
+
// ── Highlight refresh ─────────────────────────────────────────────
private void RefreshCardHighlights()
@@ -378,12 +582,10 @@ private void RefreshCardHighlights()
bool isSelected = cls == _selectedClass;
bool isCurrent = cls == _currentClass;
- // Adventurer is always unlocked. Advanced classes are locked if level requirement is not met.
bool isLocked = levelLocked && cls != PlayerClass.Adventurer;
if (isLocked)
{
- // Gray out and make semi-transparent
_cardPanels[i].Modulate = new Color(0.4f, 0.4f, 0.4f, 0.7f);
_cardPanels[i].AddStyleboxOverride("panel", MakeCardStyle(CardBgNormal, CardBorderNormal));
}
@@ -404,5 +606,71 @@ private void RefreshCardHighlights()
}
}
}
+
+ private void RefreshSkillTab()
+ {
+ // Update slots
+ for (int i = 0; i < 3; i++)
+ {
+ if (i < _equippedSkillIds.Count)
+ {
+ string skillId = _equippedSkillIds[i];
+ var def = GetSkillDef(skillId);
+ if (def != null)
+ {
+ _skillSlotLabels[i].Text = $"Slot {i + 1}: {def.Emoji} {def.DisplayName}";
+ }
+ else
+ {
+ _skillSlotLabels[i].Text = $"Slot {i + 1}: Empty";
+ }
+ }
+ else
+ {
+ _skillSlotLabels[i].Text = $"Slot {i + 1}: Empty";
+ }
+ }
+
+ // Rebuild grid
+ var gridBox = _skillTabContent.GetNodeOrNull("ScrollContainer/SkillGrid");
+ if (gridBox != null)
+ {
+ foreach (Node child in gridBox.GetChildren())
+ {
+ child.QueueFree();
+ }
+
+ _skillCardPanels = new Panel[_availableSkills.Count];
+
+ for (int i = 0; i < _availableSkills.Count; i++)
+ {
+ var def = _availableSkills[i];
+ var card = BuildSkillCard(def);
+
+ bool isEquipped = _equippedSkillIds.Contains(def.Id);
+
+ card.AddStyleboxOverride("panel", MakeCardStyle(
+ isEquipped ? CardBgSelected : CardBgNormal,
+ isEquipped ? SkillLabelColor : CardBorderNormal,
+ isEquipped ? 3 : 2
+ ));
+
+ gridBox.AddChild(card);
+ _skillCardPanels[i] = card;
+ }
+ }
+ }
+
+ private SkillDefinition GetSkillDef(string id)
+ {
+ if (_availableSkills == null) return null;
+ foreach (var def in _availableSkills)
+ {
+ if (def.Id == id) return def;
+ }
+ return null;
+ }
}
+
+
}
\ No newline at end of file
diff --git a/Scripts/UI/PlayerHud.cs b/Scripts/UI/PlayerHud.cs
index a6da0d5..2e6046e 100644
--- a/Scripts/UI/PlayerHud.cs
+++ b/Scripts/UI/PlayerHud.cs
@@ -17,7 +17,19 @@ public class PlayerHud : CanvasLayer
{
["basic_attack"] = "res://Assets/SkillAnimation/slash-icon.png",
["bow_attack"] = "res://Assets/SkillAnimation/arrow.png",
+ ["triple_arrow"] = "res://Assets/SkillAnimation/arrow_multi.png",
+ ["ricochet_arrow"] = "res://Assets/SkillAnimation/arrow_ricochet.png",
["fireball"] = "res://Assets/SkillAnimation/fireball.png",
+ ["triple_fireball"] = "res://Assets/SkillAnimation/fireball_multi.png",
+ ["giant_fireball"] = "res://Assets/SkillAnimation/fireball3.png",
+ ["flying_sword"] = "res://Assets/SkillAnimation/fly_sword.png",
+ ["defense_stance"] = "res://Assets/Characters/warrior/defense.png",
+ ["magic_slash"] = "res://Assets/SkillAnimation/magic_slash-2.png",
+ ["ice_spear"] = "res://Assets/SkillAnimation/ice_spear-4.png",
+ ["digit_arrow"] = "res://Assets/SkillAnimation/digit_arrow.png",
+ ["super_arrow"] = "res://Assets/SkillAnimation/super_arrow.png",
+ ["roundhouse_slash"] = "res://Assets/SkillAnimation/roundhouse-slash.png",
+ ["knight_explose"] = "res://Assets/SkillAnimation/knight_explose.png",
};
private readonly List _skillSlots = new List();