diff --git a/src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png b/src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png new file mode 100644 index 0000000..e4888fa Binary files /dev/null and b/src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png differ diff --git a/src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png.import b/src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png.import new file mode 100644 index 0000000..3c0efa1 --- /dev/null +++ b/src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwhfubra1nngo" +path="res://.godot/imported/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png-75b006486e123ec921097ff57277448d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png" +dest_files=["res://.godot/imported/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png-75b006486e123ec921097ff57277448d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/interactive_objects.png b/src/assets/gfx/interactive_objects.png index 38cce49..17150ce 100644 Binary files a/src/assets/gfx/interactive_objects.png and b/src/assets/gfx/interactive_objects.png differ diff --git a/src/assets/gfx/ui/page_parchment.png b/src/assets/gfx/ui/page_parchment.png new file mode 100644 index 0000000..420792e Binary files /dev/null and b/src/assets/gfx/ui/page_parchment.png differ diff --git a/src/assets/gfx/ui/page_parchment.png.import b/src/assets/gfx/ui/page_parchment.png.import new file mode 100644 index 0000000..e644764 --- /dev/null +++ b/src/assets/gfx/ui/page_parchment.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyuxd6u1rql6g" +path="res://.godot/imported/page_parchment.png-e23a05579e6d3c503a24099d3a6e5114.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/page_parchment.png" +dest_files=["res://.godot/imported/page_parchment.png-e23a05579e6d3c503a24099d3a6e5114.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/ui/page_parchment_small.png b/src/assets/gfx/ui/page_parchment_small.png new file mode 100644 index 0000000..176ee5d Binary files /dev/null and b/src/assets/gfx/ui/page_parchment_small.png differ diff --git a/src/assets/gfx/ui/page_parchment_small.png.import b/src/assets/gfx/ui/page_parchment_small.png.import new file mode 100644 index 0000000..013dd02 --- /dev/null +++ b/src/assets/gfx/ui/page_parchment_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://gwy6pqap2ye6" +path="res://.godot/imported/page_parchment_small.png-8a3d2f585c6472a91916a3e5ac9e9d55.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/page_parchment_small.png" +dest_files=["res://.godot/imported/page_parchment_small.png-8a3d2f585c6472a91916a3e5ac9e9d55.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/ui/spellbook_cover.png b/src/assets/gfx/ui/spellbook_cover.png new file mode 100644 index 0000000..e8c30ce Binary files /dev/null and b/src/assets/gfx/ui/spellbook_cover.png differ diff --git a/src/assets/gfx/ui/spellbook_cover.png.import b/src/assets/gfx/ui/spellbook_cover.png.import new file mode 100644 index 0000000..97efca2 --- /dev/null +++ b/src/assets/gfx/ui/spellbook_cover.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://durq5fotwkgr6" +path="res://.godot/imported/spellbook_cover.png-6df9bfb306806f1516407650e45bb0fb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/spellbook_cover.png" +dest_files=["res://.godot/imported/spellbook_cover.png-6df9bfb306806f1516407650e45bb0fb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/ui/spine_leather.png b/src/assets/gfx/ui/spine_leather.png new file mode 100644 index 0000000..9539f8f Binary files /dev/null and b/src/assets/gfx/ui/spine_leather.png differ diff --git a/src/assets/gfx/ui/spine_leather.png.import b/src/assets/gfx/ui/spine_leather.png.import new file mode 100644 index 0000000..15b91a4 --- /dev/null +++ b/src/assets/gfx/ui/spine_leather.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://biebn18mwedj" +path="res://.godot/imported/spine_leather.png-c3c7ab9353c466f73a587267e976e935.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/spine_leather.png" +dest_files=["res://.godot/imported/spine_leather.png-c3c7ab9353c466f73a587267e976e935.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/scenes/attack_spell_earth_spike.tscn b/src/scenes/attack_spell_earth_spike.tscn new file mode 100644 index 0000000..e50a924 --- /dev/null +++ b/src/scenes/attack_spell_earth_spike.tscn @@ -0,0 +1,46 @@ +[gd_scene format=3 uid="uid://c8earthspike0"] + +[ext_resource type="Script" path="res://scripts/attack_spell_earth_spike.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] +[ext_resource type="AudioStream" uid="uid://cij3m0pt7r5ga" path="res://assets/audio/sfx/wizard/animevox/impact_1769364721189.wav" id="3_sfx"] + +[sub_resource type="Gradient" id="Gradient_earth"] +offsets = PackedFloat32Array(0.58, 0.72) +colors = PackedColorArray(0.8, 0.5, 0.2, 1, 0.8, 0.5, 0.2, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_earth"] +gradient = SubResource("Gradient_earth") +fill = 1 +fill_from = Vector2(0.5, 0.5) +fill_to = Vector2(0.95, 0.1) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_earth"] +size = Vector2(16, 16) + +[node name="EarthSpikeSpell" type="Node2D"] +z_index = 4 +script = ExtResource("1_script") + +[node name="Sprite2D" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 3740 + +[node name="SpikeLight" type="PointLight2D" parent="."] +color = Color(0.6, 0.4, 0.2, 1) +texture = SubResource("GradientTexture2D_earth") +texture_scale = 0.5 + +[node name="Area2D" type="Area2D" parent="."] +collision_layer = 4 +collision_mask = 3 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"] +shape = SubResource("RectangleShape2D_earth") + +[node name="SfxSpike" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("3_sfx") +max_distance = 1456.0 +attenuation = 5.46 +bus = &"Sfx" diff --git a/src/scenes/attack_spell_water_bubble.tscn b/src/scenes/attack_spell_water_bubble.tscn new file mode 100644 index 0000000..da29d10 --- /dev/null +++ b/src/scenes/attack_spell_water_bubble.tscn @@ -0,0 +1,31 @@ +[gd_scene format=3 uid="uid://d0qjvnaqyb36p"] + +[ext_resource type="Script" path="res://scripts/attack_spell_water_bubble.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] + +[sub_resource type="CircleShape2D" id="CircleShape2D_bubble"] +radius = 8.0 + +[node name="WaterBubbleSpell" type="CharacterBody2D"] +collision_layer = 4 +collision_mask = 66 +script = ExtResource("1_script") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("CircleShape2D_bubble") + +[node name="Sprite2D" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 2692 + +[node name="Area2D" type="Area2D" parent="."] +collision_layer = 0 +collision_mask = 3 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"] +shape = SubResource("CircleShape2D_bubble") + +[node name="LifetimeTimer" type="Timer" parent="."] +one_shot = true diff --git a/src/scenes/book_paper.tscn b/src/scenes/book_paper.tscn new file mode 100644 index 0000000..f81763f --- /dev/null +++ b/src/scenes/book_paper.tscn @@ -0,0 +1,32 @@ +[gd_scene format=3 uid="uid://copkx0vek0okx"] + +[ext_resource type="Texture2D" uid="uid://gwy6pqap2ye6" path="res://assets/gfx/ui/page_parchment_small.png" id="1_ttuta"] + +[sub_resource type="Animation" id="Animation_ttuta"] +resource_name = "turn_page" +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PagePolygon:polygon") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(), +"transitions": PackedFloat32Array(), +"update": 0, +"values": [] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_i033y"] +_data = { +&"turn_page": SubResource("Animation_ttuta") +} + +[node name="BookPaper" type="Node2D" unique_id=723859984] + +[node name="PagePolygon" type="Polygon2D" parent="." unique_id=1476858006] +texture = ExtResource("1_ttuta") +polygon = PackedVector2Array(138, 0, 182, 0, 182, 256, 138, 256, 92, 256, 42, 256, 0, 256, 0, 0, 42, 0, 92, 0) + +[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=2024937129] +libraries/ = SubResource("AnimationLibrary_i033y") diff --git a/src/scenes/inventory_ui.tscn b/src/scenes/inventory_ui.tscn index a6d27db..e4dbeb6 100644 --- a/src/scenes/inventory_ui.tscn +++ b/src/scenes/inventory_ui.tscn @@ -7,6 +7,7 @@ [ext_resource type="AudioStream" uid="uid://bbnby1sso3f4v" path="res://assets/audio/sfx/pickups/bite-food-02.mp3" id="5_1dxi5"] [ext_resource type="AudioStream" uid="uid://umoxmryvbm01" path="res://assets/audio/sfx/cloth/leather_cloth_01.wav.mp3" id="6_dqfnd"] [ext_resource type="AudioStream" uid="uid://djw6c5rb4mm60" path="res://assets/audio/sfx/cloth/leather_cloth_02.wav.mp3" id="7_ngbl7"] +[ext_resource type="Script" path="res://scripts/spell_book_ui.gd" id="8_spell_book"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_selection"] bg_color = Color(0, 0, 0, 0) @@ -34,28 +35,25 @@ script = ExtResource("1_inventory_ui") [node name="InventoryContainer" type="Control" parent="." unique_id=-294967296] visible = false +clip_contents = true layout_mode = 3 anchors_preset = 3 anchor_left = 1.0 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 +offset_left = -610.0 +offset_top = -450.0 grow_horizontal = 0 grow_vertical = 0 -size_flags_horizontal = 3 -size_flags_vertical = 3 [node name="MarginContainer" type="MarginContainer" parent="InventoryContainer" unique_id=935107028] layout_mode = 1 -anchors_preset = 3 -anchor_left = 1.0 -anchor_top = 1.0 +anchors_preset = 15 +anchor_left = 0.0 +anchor_top = 0.0 anchor_right = 1.0 anchor_bottom = 1.0 -offset_left = -610.0 -offset_top = -226.0 -grow_horizontal = 0 -grow_vertical = 0 [node name="Background" type="ColorRect" parent="InventoryContainer/MarginContainer" unique_id=705032704] layout_mode = 2 @@ -74,8 +72,27 @@ theme_override_constants/margin_bottom = 16 layout_mode = 2 theme_override_constants/separation = 4 +[node name="TabRow" type="HBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="InventoryTab" type="Button" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow"] +layout_mode = 2 +text = "Inventory" + +[node name="SpellBookTab" type="Button" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow"] +layout_mode = 2 +text = "Spell Book" + +[node name="SpellBookPanel" type="Control" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +script = ExtResource("8_spell_book") +visible = false + [node name="HBox" type="HBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer" unique_id=1705032704] layout_mode = 2 +size_flags_vertical = 3 theme_override_constants/separation = 10 [node name="StatsPanel" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox" unique_id=-1589934592] @@ -162,8 +179,9 @@ text = "0/100 horizontal_alignment = 2 [node name="InventoryPanel" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox" unique_id=1115098112] -custom_minimum_size = Vector2(400, 0) +custom_minimum_size = Vector2(240, 0) layout_mode = 2 +size_flags_horizontal = 3 theme_override_constants/separation = 5 [node name="EquipmentLabel" type="Label" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel" unique_id=2115098112] @@ -193,7 +211,7 @@ theme_override_font_sizes/font_size = 16 text = "Inventory" [node name="InventoryScroll" type="ScrollContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel" unique_id=820130816] -custom_minimum_size = Vector2(380, 120) +custom_minimum_size = Vector2(220, 120) layout_mode = 2 [node name="InventoryVBox" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel/InventoryScroll" unique_id=1820130816] diff --git a/src/scenes/spellbook.tscn b/src/scenes/spellbook.tscn new file mode 100644 index 0000000..b9dad14 --- /dev/null +++ b/src/scenes/spellbook.tscn @@ -0,0 +1,106 @@ +[gd_scene format=3 uid="uid://c2spellbook0"] + +[sub_resource type="Gradient" id="Gradient_shadow"] +offsets = PackedFloat32Array(0, 1) +colors = PackedColorArray(0, 0, 0, 0.35, 0, 0, 0, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_shadow"] +gradient = SubResource("Gradient_shadow") +width = 28 +height = 14 +fill = 1 +fill_from = Vector2(0.5, 0.5) +fill_to = Vector2(0, 0) + +[sub_resource type="CircleShape2D" id="CircleShape2D_interact"] +radius = 14.0 + +[sub_resource type="Animation" id="Animation_idle"] +resource_name = "idle" +length = 2.2 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PageLeft:position:y") +tracks/0/interp = 2 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.55, 1.1, 1.65, 2.2), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 1, +"values": [0.0, 1.2, 0.0, -0.8, 0.0] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("PageRight:position:y") +tracks/1/interp = 2 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.55, 1.1, 1.65, 2.2), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 1, +"values": [0.0, -0.8, 0.0, 1.2, 0.0] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("BookBody:position:y") +tracks/2/interp = 2 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.55, 1.1, 1.65, 2.2), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 1, +"values": [0.0, 0.6, 0.0, -0.3, 0.0] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_book"] +_data = { +"idle": SubResource("Animation_idle") +} + +[node name="SpellBook" type="Node2D"] +z_index = 0 + +[node name="Shadow" type="Sprite2D" parent="."] +z_index = -1 +position = Vector2(0, 8) +y_sort_enabled = true +texture = SubResource("GradientTexture2D_shadow") + +[node name="BookBody" type="Node2D" parent="."] +y_sort_enabled = true + +[node name="Spine" type="Polygon2D" parent="BookBody"] +z_index = 0 +color = Color(0.32, 0.2, 0.1, 1) +polygon = PackedVector2Array(-3, -10, 3, -10, 3, 10, -3, 10) + +[node name="PageLeft" type="Polygon2D" parent="BookBody"] +z_index = 1 +color = Color(0.98, 0.95, 0.88, 1) +polygon = PackedVector2Array(-18, -10, -3, -10, -3, 10, -18, 10) + +[node name="PageRight" type="Polygon2D" parent="BookBody"] +z_index = 1 +color = Color(0.98, 0.95, 0.88, 1) +polygon = PackedVector2Array(3, -10, 18, -10, 18, 10, 3, 10) + +[node name="SpineLine" type="Line2D" parent="BookBody"] +z_index = 2 +width = 1.2 +default_color = Color(0.18, 0.1, 0.05, 1) +points = PackedVector2Array(-3, -10, 3, -10, 3, 10, -3, 10, -3, -10) + +[node name="InteractArea" type="Area2D" parent="."] +collision_layer = 0 +collision_mask = 1 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractArea"] +shape = SubResource("CircleShape2D_interact") + +[node name="AnimationPlayer" type="AnimationPlayer" parent="."] +libraries/ = SubResource("AnimationLibrary_book") +autoplay = "idle" diff --git a/src/scripts/attack_spell_earth_spike.gd b/src/scripts/attack_spell_earth_spike.gd new file mode 100644 index 0000000..0bc559a --- /dev/null +++ b/src/scripts/attack_spell_earth_spike.gd @@ -0,0 +1,118 @@ +extends Node2D + +# Earth spike spell — same behaviour as frostspike, different frames (3740–3743) and brown tint. + +var player_owner: Node = null +var damage: float = 15.0 +var damage_mult: float = 1.0 +var is_center: bool = false +var skip_sfx: bool = false +var damage_dealt: bool = false +var elapsed: float = 0.0 +var _frames: Array = [3740, 3741, 3742, 3743] + +@onready var sprite: Sprite2D = $Sprite2D +@onready var spike_light: PointLight2D = $SpikeLight +@onready var hit_area: Area2D = $Area2D + +func _ready() -> void: + if spike_light: + spike_light.color = Color(0.6, 0.4, 0.2) + spike_light.energy = 1.0 + spike_light.enabled = true + if sprite: + sprite.frame = _frames[0] + sprite.modulate = Color(0.85, 0.65, 0.4) + if not skip_sfx and has_node("SfxSpike"): + $SfxSpike.play() + if is_center: + _spawn_adjacent_after_delay() + +func setup(target_pos: Vector2, owner_player: Node, damage_value: float, center: bool, scale_mult: float = 1.0, dmg_mult: float = 1.0, _skip_sfx: bool = false) -> void: + global_position = target_pos + player_owner = owner_player + damage = damage_value + damage_mult = dmg_mult + is_center = center + skip_sfx = _skip_sfx + if scale_mult != 1.0: + scale = Vector2(scale_mult, scale_mult) + +func _spawn_adjacent_after_delay() -> void: + await get_tree().create_timer(0.5).timeout + if not is_instance_valid(self): + return + if has_node("SfxSpike"): + $SfxSpike.play() + var gw = get_tree().get_first_node_in_group("game_world") + if not gw or not gw.has_method("_get_adjacent_valid_spell_tile_centers"): + _finish_center_spike() + return + var player_pos = player_owner.global_position if player_owner else global_position + var adjacent = gw._get_adjacent_valid_spell_tile_centers(global_position, player_pos) + var scene = load("res://scenes/attack_spell_earth_spike.tscn") as PackedScene + if not scene: + _finish_center_spike() + return + var par = get_parent() + for pos in adjacent: + var sp = scene.instantiate() + sp.setup(pos, player_owner, damage, false, 1.0, 1.0, true) + par.add_child(sp) + await get_tree().create_timer(0.25).timeout + if not is_instance_valid(self): + return + var third = scene.instantiate() + third.setup(global_position, player_owner, damage, false, 2.0, 2.0, false) + par.add_child(third) + _finish_center_spike() + +func _finish_center_spike() -> void: + if hit_area: + hit_area.set_deferred("monitoring", false) + queue_free() + +func _process(delta: float) -> void: + elapsed += delta + if not damage_dealt and player_owner and player_owner.is_multiplayer_authority() and elapsed >= 0.05: + _deal_damage_once() + damage_dealt = true + if sprite and _frames.size() > 0: + var idx = min(int(elapsed / 0.05), _frames.size() - 1) + sprite.frame = _frames[idx] + if not is_center and elapsed >= 0.25: + if hit_area: + hit_area.set_deferred("monitoring", false) + queue_free() + +func _deal_damage_once() -> void: + if not hit_area: + return + for body in hit_area.get_overlapping_bodies(): + if body == player_owner: + continue + var final_damage = damage * damage_mult + if player_owner and player_owner.character_stats: + var int_stat = player_owner.character_stats.baseStats.int + player_owner.character_stats.get_pass("int") + final_damage += int_stat * 0.5 + var attacker_pos = player_owner.global_position if player_owner else global_position + if body.is_in_group("player") and body.has_method("rpc_take_damage"): + var pid = body.get_multiplayer_authority() + if pid != 0: + if multiplayer.get_unique_id() == pid: + body.take_damage(final_damage, attacker_pos, false, false) + else: + body.rpc_take_damage.rpc_id(pid, final_damage, attacker_pos, false, false) + else: + body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false) + elif body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + var eid = body.get_multiplayer_authority() + if eid != 0: + if multiplayer.get_unique_id() == eid: + body.take_damage(final_damage, attacker_pos, false, false, false) + else: + body.rpc_take_damage.rpc_id(eid, final_damage, attacker_pos, false, false, false) + else: + body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, false) + elif body.has_method("can_be_destroyed") and body.can_be_destroyed() and body.has_method("take_damage"): + body.take_damage(final_damage, attacker_pos) diff --git a/src/scripts/attack_spell_earth_spike.gd.uid b/src/scripts/attack_spell_earth_spike.gd.uid new file mode 100644 index 0000000..c22c3b8 --- /dev/null +++ b/src/scripts/attack_spell_earth_spike.gd.uid @@ -0,0 +1 @@ +uid://jqfj6bsutmdp diff --git a/src/scripts/attack_spell_water_bubble.gd b/src/scripts/attack_spell_water_bubble.gd new file mode 100644 index 0000000..c0042f6 --- /dev/null +++ b/src/scripts/attack_spell_water_bubble.gd @@ -0,0 +1,136 @@ +extends CharacterBody2D +## Water bubble projectile: shoots in direction player points, bounces off walls, +## lives 5s then pops. Pops on enemy contact and deals damage. +## Frames 2692-2695 ping-pong while moving; 2696-2705 on pop. + +var player_owner: Node = null +var damage: float = 15.0 +var speed: float = 180.0 +var lifetime: float = 5.0 +var elapsed: float = 0.0 +var popped: bool = false +var pop_elapsed: float = 0.0 + +# shade_spell_effects.png 105h x 79v +var _bubble_frames: Array = [2692, 2693, 2694, 2695] +var _pop_frames: Array = [2696, 2697, 2698, 2699, 2700, 2701, 2702, 2703, 2704, 2705] +var _ping_pong_dir: int = 1 +var _bubble_frame_idx: int = 0 +var _bubble_anim_t: float = 0.0 + +@onready var sprite: Sprite2D = $Sprite2D +@onready var hit_area: Area2D = $Area2D +@onready var lifetime_timer: Timer = $LifetimeTimer + +func _ready() -> void: + if hit_area: + hit_area.body_entered.connect(_on_body_entered) + if lifetime_timer: + lifetime_timer.wait_time = lifetime + lifetime_timer.timeout.connect(_on_lifetime_timeout) + lifetime_timer.start() + if sprite: + sprite.frame = _bubble_frames[0] + +func setup(start_pos: Vector2, direction: Vector2, owner_player: Node, damage_value: float) -> void: + global_position = start_pos + velocity = direction.normalized() * speed + player_owner = owner_player + damage = damage_value + +func _physics_process(delta: float) -> void: + if popped: + pop_elapsed += delta + if sprite and _pop_frames.size() > 0: + var idx = min(int(pop_elapsed / 0.04), _pop_frames.size() - 1) + sprite.frame = _pop_frames[idx] + if pop_elapsed >= 0.04 * _pop_frames.size(): + queue_free() + return + elapsed += delta + # Ping-pong bubble frames 2692-2695 + _bubble_anim_t += delta + if _bubble_anim_t >= 0.08: + _bubble_anim_t = 0.0 + _bubble_frame_idx += _ping_pong_dir + if _bubble_frame_idx <= 0: + _ping_pong_dir = 1 + _bubble_frame_idx = 0 + elif _bubble_frame_idx >= _bubble_frames.size() - 1: + _ping_pong_dir = -1 + _bubble_frame_idx = _bubble_frames.size() - 1 + if sprite: + sprite.frame = _bubble_frames[_bubble_frame_idx] + move_and_slide() + # Bounce off walls + for i in get_slide_collision_count(): + var col = get_slide_collision(i) + var collider = col.get_collider() + if collider and collider.is_in_group("enemy"): + _pop_on_enemy(collider) + return + # Wall: reflect velocity + velocity = velocity.bounce(col.get_normal()) + +func _on_body_entered(body: Node) -> void: + if popped: + return + if body == player_owner: + return + if body.is_in_group("enemy"): + _pop_on_enemy(body) + +func _on_lifetime_timeout() -> void: + if popped: + return + _pop_effect_only() + +func _pop_on_enemy(enemy: Node) -> void: + if popped: + return + popped = true + velocity = Vector2.ZERO + if hit_area: + hit_area.set_deferred("monitoring", false) + # Deal damage (authority only) + if player_owner and player_owner.is_multiplayer_authority(): + var final_damage = damage + if player_owner.character_stats: + var int_stat = player_owner.character_stats.baseStats.int + player_owner.character_stats.get_pass("int") + final_damage += int_stat * 0.5 + var attacker_pos = player_owner.global_position if player_owner else global_position + if enemy.is_in_group("enemy") and enemy.has_method("rpc_take_damage"): + var eid = enemy.get_multiplayer_authority() + if eid != 0: + if multiplayer.get_unique_id() == eid: + enemy.take_damage(final_damage, attacker_pos, false, false, false) + else: + enemy.rpc_take_damage.rpc_id(eid, final_damage, attacker_pos, false, false, false) + else: + enemy.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, false) + elif enemy.is_in_group("player") and enemy.has_method("rpc_take_damage"): + var pid = enemy.get_multiplayer_authority() + if pid != 0: + if multiplayer.get_unique_id() == pid: + enemy.take_damage(final_damage, attacker_pos, false, false) + else: + enemy.rpc_take_damage.rpc_id(pid, final_damage, attacker_pos, false, false) + else: + enemy.rpc_take_damage.rpc(final_damage, attacker_pos, false, false) + _start_pop_animation() + +func _pop_effect_only() -> void: + if popped: + return + popped = true + velocity = Vector2.ZERO + if hit_area: + hit_area.set_deferred("monitoring", false) + if lifetime_timer: + lifetime_timer.stop() + _start_pop_animation() + +func _start_pop_animation() -> void: + pop_elapsed = 0.0 + if sprite and _pop_frames.size() > 0: + sprite.frame = _pop_frames[0] diff --git a/src/scripts/attack_spell_water_bubble.gd.uid b/src/scripts/attack_spell_water_bubble.gd.uid new file mode 100644 index 0000000..4d9e682 --- /dev/null +++ b/src/scripts/attack_spell_water_bubble.gd.uid @@ -0,0 +1 @@ +uid://bpifj6br77wlk diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index 966aae5..7d32f97 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -71,6 +71,12 @@ var equipment: Dictionary = { "accessory": null } +# Spell book (Diablo 1 style): learnt spells and hotkey assignment +# learnt_spells: array of spell ids ("flames", "frostspike", "healing") +# spell_hotkeys: slot "1", "2", "3" -> spell id (or empty string) +var learnt_spells: Array = [] +var spell_hotkeys: Dictionary = {"1": "", "2": "", "3": ""} + @export var baseStats = { "str": 10, "dex": 10, @@ -550,6 +556,40 @@ func drop_equipment(iItem: Item): drop_item(iItem) pass +# Returns spell id for tome item, or empty string if not a learnable tome +func _tome_to_spell_id(iItem: Item) -> String: + if not iItem or iItem.weapon_type != Item.WeaponType.SPELLBOOK: + return "" + if iItem.item_name == "Tome of Flames": + return "flames" + if iItem.item_name == "Tome of Frostspike": + return "frostspike" + if iItem.item_name == "Tome of Healing": + return "healing" + if iItem.item_name == "Tome of Water Bubble": + return "water_bubble" + if iItem.item_name == "Tome of Earth Spike": + return "earth_spike" + return "" + +# Consume tome and add spell to learnt_spells. Returns true if spell was learnt (item consumed). +func learn_spell_from_tome(iItem: Item) -> bool: + var spell_id := _tome_to_spell_id(iItem) + if spell_id.is_empty(): + return false + if spell_id in learnt_spells: + return false + # Remove from equipment if in offhand + if equipment.get("offhand") == iItem: + equipment["offhand"] = null + # Remove from inventory + var idx = inventory.find(iItem) + if idx >= 0: + inventory.remove_at(idx) + learnt_spells.append(spell_id) + emit_signal("character_changed", self) + return true + func add_item(iItem: Item): # Try to stack with existing items if possible if iItem.can_have_multiple_of: @@ -566,11 +606,14 @@ func add_item(iItem: Item): # Auto-equip if slot is empty (only for equippable items) # BUT: Do NOT auto-equip BOW weapons (they require arrows in off-hand) + # Do NOT auto-equip SPELLBOOK (tomes): player must "use" them to learn the spell if iItem.item_type == Item.ItemType.Equippable and iItem.equipment_type != Item.EquipmentType.NONE: - # Skip auto-equip for BOW weapons if iItem.equipment_type == Item.EquipmentType.MAINHAND and iItem.weapon_type == Item.WeaponType.BOW: emit_signal("character_changed", self) return + if iItem.weapon_type == Item.WeaponType.SPELLBOOK: + emit_signal("character_changed", self) + return var slot_key = "" match iItem.equipment_type: diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 8768c46..a868238 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -3266,6 +3266,8 @@ func _init_fog_of_war(): combined_seen = _create_seen_array(dungeon_data.map_size) explored_map = _create_seen_array(dungeon_data.map_size) + # Invalidate fog tile->room index so it is rebuilt for this level's rooms (avoids stale index crash) + fog_tile_to_room_index.resize(0) # Clear corridor cache when initializing new fog cached_corridor_mask.clear() cached_corridor_rooms.clear() @@ -3656,7 +3658,7 @@ func _find_room_at_tile(tile: Vector2i) -> Dictionary: if idx < 0 or idx >= fog_tile_to_room_index.size(): return {} var ri: int = fog_tile_to_room_index[idx] - if ri < 0: + if ri < 0 or ri >= dungeon_data.rooms.size(): return {} return dungeon_data.rooms[ri] @@ -3831,10 +3833,8 @@ func _update_canvas_modulate_by_torches() -> void: _torch_darken_target_scale = _torch_scale_to_display(raw) target_scale = _torch_darken_target_scale var delta := get_process_delta_time() - if not (multiplayer.has_multiplayer_peer() and multiplayer.is_server()): - _torch_darken_current_scale = lerpf(_torch_darken_current_scale, target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0)) - else: - _torch_darken_current_scale = target_scale + # Always lerp so light changes smoothly (server and single player) + _torch_darken_current_scale = lerpf(_torch_darken_current_scale, target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0)) var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE) cm.color = Color(s, s, s) _sync_ambient_to_canvas_modulate() @@ -3890,15 +3890,13 @@ func _reapply_torch_darkening() -> void: raw = _TORCH_DARKEN_DEFAULT_RAW var t := _torch_scale_to_display(raw) _torch_darken_target_scale = t - _torch_darken_current_scale = t + # Don't set current_scale or cm.color here — let _update_canvas_modulate_by_torches lerp to target so light transitions smoothly var room_id := "" if in_room: room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) _torch_darken_initialized = true _torch_darken_in_room_last = in_room _torch_darken_last_room_id = room_id - cm.color = Color(t, t, t) - _sync_ambient_to_canvas_modulate() if abs(t - _last_darkness_debug_scale) > 0.005 or _last_darkness_debug_scale < 0: _last_darkness_debug_scale = t var reason := "" diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index ec274d0..fbfed1b 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -37,6 +37,11 @@ var equipment_selection_index: int = 0 # Current equipment slot index (0-5: main @onready var sfx_potion: AudioStreamPlayer2D = $SfxPotion @onready var sfx_food: AudioStreamPlayer2D = $SfxFood @onready var sfx_armour: AudioStreamPlayer2D = $SfxArmour +@onready var tab_row: HBoxContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow +@onready var inventory_tab_btn: Button = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow/InventoryTab +@onready var spell_book_tab_btn: Button = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow/SpellBookTab +@onready var spell_book_panel: Control = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/SpellBookPanel +@onready var inventory_hbox: HBoxContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox # Bar layout constants (align X/Y + bar across rows) const _BAR_WIDTH: int = 100 @@ -114,6 +119,9 @@ var quantity_font: Font = null # Selection animation var selection_animation_time: float = 0.0 +# Tab: "inventory" or "spell_book" +var _current_tab: String = "inventory" + func _ready(): # Set layer to be above game but below chat layer = 150 @@ -137,6 +145,12 @@ func _ready(): # Setup selection rectangle (already in scene, just configure it) _setup_selection_rectangle() + # Spell Book / Inventory tabs + if inventory_tab_btn: + inventory_tab_btn.pressed.connect(_on_inventory_tab_pressed) + if spell_book_tab_btn: + spell_book_tab_btn.pressed.connect(_on_spell_book_tab_pressed) + # Find local player call_deferred("_find_local_player") @@ -1113,7 +1127,16 @@ func _format_item_info(item: Item) -> String: text += "\n\n" # Controls - if item.item_type == Item.ItemType.Equippable: + if item.weapon_type == Item.WeaponType.SPELLBOOK: + if local_player and local_player.character_stats: + var spell_id = local_player.character_stats._tome_to_spell_id(item) if local_player.character_stats.has_method("_tome_to_spell_id") else "" + if spell_id != "" and spell_id in local_player.character_stats.learnt_spells: + text += "Already learnt." + else: + text += "Press F to learn spell" + else: + text += "Press F to learn spell" + elif item.item_type == Item.ItemType.Equippable: if selected_type == "equipment": text += "Press F to unequip" else: @@ -1445,6 +1468,13 @@ func _input(event): if not is_open: return + # B key: switch to Spell Book tab when inventory is open + if event is InputEventKey and event.keycode == KEY_B and event.pressed and not event.echo: + _current_tab = "spell_book" if _current_tab == "inventory" else "inventory" + _apply_tab_visibility() + get_viewport().set_input_as_handled() + return + # Arrow key navigation (use ui_left/right/up/down so keybindings work) var direction = "" var skip_repeat = event is InputEventKey and event.echo @@ -1457,7 +1487,9 @@ func _input(event): elif not skip_repeat and event.is_action_pressed("ui_down"): direction = "down" if direction != "": - if selected_type == "level_up_stat": + if _current_tab == "spell_book": + pass + elif selected_type == "level_up_stat": _navigate_level_up_stats(direction) elif selected_type == "equipment": _navigate_equipment(direction) @@ -1499,6 +1531,16 @@ func _handle_f_key(): if selected_type == "equipment" and selected_slot != "": var equipped_item = char_stats.equipment[selected_slot] if equipped_item: + # Tome in offhand: use (F) to learn spell and consume tome + if equipped_item.weapon_type == Item.WeaponType.SPELLBOOK and char_stats.has_method("learn_spell_from_tome"): + if char_stats.learn_spell_from_tome(equipped_item): + _update_ui() + _update_selection_from_navigation() + _update_selection_rectangle() + _update_info_panel() + if sfx_armour: + sfx_armour.play() + return char_stats.unequip_item(equipped_item) # Play armour sound when unequipping if sfx_armour: @@ -1540,6 +1582,23 @@ func _handle_f_key(): return if selected_type == "item" and selected_item: + # Tome in inventory: use (F) to learn spell and consume tome + if selected_item.weapon_type == Item.WeaponType.SPELLBOOK and char_stats.has_method("learn_spell_from_tome"): + if char_stats.learn_spell_from_tome(selected_item): + var current_item_index = inventory_selection_row * 8 + inventory_selection_col + _update_ui() + if current_item_index < char_stats.inventory.size(): + inventory_selection_row = current_item_index / 8 + inventory_selection_col = current_item_index % 8 + elif char_stats.inventory.size() > 0: + inventory_selection_row = 0 + inventory_selection_col = 0 + _update_selection_from_navigation() + _update_selection_rectangle() + _update_info_panel() + if sfx_armour: + sfx_armour.play() + return if selected_item.item_type == Item.ItemType.Equippable and selected_item.equipment_type != Item.EquipmentType.NONE: # Remember which slot the item will be equipped to var target_slot_name = "" @@ -1820,6 +1879,7 @@ func _open_inventory(): _update_ui() _update_stats() + _apply_tab_visibility() if not local_player: _find_local_player() @@ -1841,6 +1901,37 @@ func _close_inventory(): _lock_player_controls(false) +func _on_inventory_tab_pressed(): + _current_tab = "inventory" + # Reset selection so first item gets selected (same as TAB-open flow) + selected_type = "" + selected_item = null + selected_slot = "" + inventory_selection_row = 0 + inventory_selection_col = 0 + _apply_tab_visibility() + _update_ui() + +func _on_spell_book_tab_pressed(): + _current_tab = "spell_book" + _apply_tab_visibility() + +func _apply_tab_visibility(): + var show_inv = (_current_tab == "inventory") + if inventory_hbox: + inventory_hbox.visible = show_inv + if spell_book_panel: + spell_book_panel.visible = not show_inv + if not show_inv and spell_book_panel.has_method("set_character_stats"): + if local_player and local_player.character_stats: + spell_book_panel.set_character_stats(local_player.character_stats) + if spell_book_panel.has_method("refresh"): + spell_book_panel.refresh() + if selection_rectangle: + selection_rectangle.visible = show_inv + if info_panel: + info_panel.visible = show_inv + func _lock_player_controls(lock: bool): var game_world = get_tree().get_first_node_in_group("game_world") if game_world: diff --git a/src/scripts/item_database.gd b/src/scripts/item_database.gd index ce18bb8..25ccff6 100644 --- a/src/scripts/item_database.gd +++ b/src/scripts/item_database.gd @@ -1863,6 +1863,57 @@ static func _load_all_items(): ] }) + # Tome of Water Bubble - same base as Frostspike, blue (reuse _tf_o and _tf_blue) + _register_item("tome_of_water_bubble", { + "item_name": "Tome of Water Bubble", + "description": "A spellbook containing water magic", + "item_type": Item.ItemType.Equippable, + "equipment_type": Item.EquipmentType.OFFHAND, + "weapon_type": Item.WeaponType.SPELLBOOK, + "spriteFrame": 11 * 20 + 13, + "modifiers": {}, + "buy_cost": 100, + "sell_worth": 30, + "weight": 1.5, + "rarity": ItemRarity.UNCOMMON, + "colorReplacements": [ + {"original": _tf_o[0], "replace": _tf_blue[0]}, + {"original": _tf_o[1], "replace": _tf_blue[1]}, + {"original": _tf_o[2], "replace": _tf_blue[2]}, + {"original": _tf_o[3], "replace": _tf_blue[3]}, + {"original": _tf_o[4], "replace": _tf_blue[4]} + ] + }) + + # Tome of Earth Spike - same frame as Frostspike, brown + var _te_brown = [ + Color(0.5, 0.35, 0.2), + Color(0.35, 0.25, 0.15), + Color(0.6, 0.45, 0.3), + Color(0.3, 0.2, 0.1), + Color(0.45, 0.35, 0.25) + ] + _register_item("tome_of_earth_spike", { + "item_name": "Tome of Earth Spike", + "description": "A spellbook containing earth magic", + "item_type": Item.ItemType.Equippable, + "equipment_type": Item.EquipmentType.OFFHAND, + "weapon_type": Item.WeaponType.SPELLBOOK, + "spriteFrame": 11 * 20 + 13, + "modifiers": {}, + "buy_cost": 100, + "sell_worth": 30, + "weight": 1.5, + "rarity": ItemRarity.UNCOMMON, + "colorReplacements": [ + {"original": _tf_o[0], "replace": _te_brown[0]}, + {"original": _tf_o[1], "replace": _te_brown[1]}, + {"original": _tf_o[2], "replace": _te_brown[2]}, + {"original": _tf_o[3], "replace": _te_brown[3]}, + {"original": _tf_o[4], "replace": _te_brown[4]} + ] + }) + # BOMB item (sprite index 199 = row 9, col 19) _register_item("bomb", { "item_name": "Bomb", diff --git a/src/scripts/loader.gd b/src/scripts/loader.gd index 396acd9..ed0d270 100644 --- a/src/scripts/loader.gd +++ b/src/scripts/loader.gd @@ -57,7 +57,7 @@ func _warmup_bomb_and_projectiles() -> void: if n.has_method("setup"): n.setup(Vector2.ZERO, null, Vector2.ZERO, false) n.queue_free() - for path in ["res://scenes/attack_arrow.tscn", "res://scenes/sword_projectile.tscn", "res://scenes/attack_spell_flame.tscn", "res://scenes/attack_spell_frostspike.tscn", "res://scenes/attack_bomb.tscn"]: + for path in ["res://scenes/attack_arrow.tscn", "res://scenes/sword_projectile.tscn", "res://scenes/attack_spell_flame.tscn", "res://scenes/attack_spell_frostspike.tscn", "res://scenes/attack_spell_water_bubble.tscn", "res://scenes/attack_spell_earth_spike.tscn", "res://scenes/attack_bomb.tscn"]: var s = load(path) as PackedScene if s: var n = s.instantiate() diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 0a52625..2f23d1f 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -84,7 +84,8 @@ var is_attacking: bool = false var is_charging_bow: bool = false # True when holding attack with bow+arrows var bow_charge_start_time: float = 0.0 var bow_charge_percentage: float = 1.0 # 0.5, 0.75, or 1.0 based on charge time -var is_charging_spell: bool = false # True when holding grab with spellbook +var is_charging_spell: bool = false # True when holding grab with spellbook or hotkey 1/2/3 +var spell_charge_hotkey_slot: String = "" # "1", "2", or "3" when charging from hotkey (else "") var spell_charge_start_time: float = 0.0 var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second) var use_spell_charge_particles: bool = false # If true, use red_star particles; if false, use AnimationIncantation @@ -116,6 +117,9 @@ var shield_block_cooldown_timer: float = 0.0 # After blocking, cannot block agai var shield_block_cooldown_duration: float = 1.2 # Seconds before can block again var shield_block_direction: Vector2 = Vector2.DOWN # Facing direction when we started blocking (locked) var was_shielding_last_frame: bool = false # For detecting shield activate transition +var _key1_was_pressed: bool = false +var _key2_was_pressed: bool = false +var _key3_was_pressed: bool = false var empty_bow_shot_attempts: int = 0 var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync) var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) @@ -124,6 +128,8 @@ var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff m var attack_arrow_scene = preload("res://scenes/attack_arrow.tscn") var flame_spell_scene = preload("res://scenes/attack_spell_flame.tscn") # Flame spell for Tome of Flames var frostspike_spell_scene = preload("res://scenes/attack_spell_frostspike.tscn") # Frostspike for Tome of Frostspike +var water_bubble_spell_scene = preload("res://scenes/attack_spell_water_bubble.tscn") # Water bubble projectile +var earth_spike_spell_scene = preload("res://scenes/attack_spell_earth_spike.tscn") # Earth spike (like frostspike) var healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch @@ -881,11 +887,11 @@ func _setup_player_appearance(): character_stats.add_item(debug_sword) print("Dwarf player ", name, " spawned with 5 bombs and debug axe/dagger/sword in inventory") - # Give Human race (Wizard) starting spellbook (Tome of Flames), Tome of Healing, Tome of Frostspike, and Hat + # Give Human race (Wizard) starting tomes in inventory; use (F) each to learn spell (spell book system) if selected_race == "Human": var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: - character_stats.equipment["offhand"] = starting_tome + character_stats.add_item(starting_tome) var tome_healing = ItemDatabase.create_item("tome_of_healing") if tome_healing: character_stats.add_item(tome_healing) @@ -2752,6 +2758,7 @@ func _handle_interactions(): # Start disarming - cancel any spell charging if is_charging_spell: is_charging_spell = false + spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() @@ -2784,35 +2791,105 @@ func _handle_interactions(): # No nearby trap - reset disarming flag is_disarming = false - # Check for spell casting (Tome of Flames, Frostspike, or Healing) - if character_stats and character_stats.equipment.has("offhand"): - var offhand_item = character_stats.equipment["offhand"] - if offhand_item and offhand_item.weapon_type == Item.WeaponType.SPELLBOOK: - var is_fire = offhand_item.item_name == "Tome of Flames" - var is_frost = offhand_item.item_name == "Tome of Frostspike" - var is_heal = offhand_item.item_name == "Tome of Healing" - if is_fire or is_frost or is_heal: + # Spell hotkey key state (for 1/2/3 casting from learnt spells) + var k1 = Input.is_key_pressed(KEY_1) + var k2 = Input.is_key_pressed(KEY_2) + var k3 = Input.is_key_pressed(KEY_3) + var key1_just_pressed = k1 and not _key1_was_pressed + var key2_just_pressed = k2 and not _key2_was_pressed + var key3_just_pressed = k3 and not _key3_was_pressed + var key1_just_released = _key1_was_pressed and not k1 + var key2_just_released = _key2_was_pressed and not k2 + var key3_just_released = _key3_was_pressed and not k3 + _key1_was_pressed = k1 + _key2_was_pressed = k2 + _key3_was_pressed = k3 + + # Check for spell casting (Tome of Flames, Frostspike, or Healing) — from offhand OR hotkey 1/2/3 + var offhand_item = character_stats.equipment["offhand"] if (character_stats and character_stats.equipment.has("offhand")) else null + var spell_from_offhand = offhand_item and offhand_item.weapon_type == Item.WeaponType.SPELLBOOK + var spell_from_hotkey = character_stats and character_stats.learnt_spells.size() > 0 and spell_charge_hotkey_slot != "" + if character_stats and (spell_from_offhand or spell_from_hotkey or character_stats.learnt_spells.size() > 0): + # Start charge from hotkey 1/2/3 if that key just pressed and slot has a learnt spell + if not is_charging_spell and not spell_from_offhand: + var slot_pressed = "" + var spell_id = "" + if key1_just_pressed and character_stats.spell_hotkeys.get("1", "") in character_stats.learnt_spells: + slot_pressed = "1" + spell_id = character_stats.spell_hotkeys.get("1", "") + elif key2_just_pressed and character_stats.spell_hotkeys.get("2", "") in character_stats.learnt_spells: + slot_pressed = "2" + spell_id = character_stats.spell_hotkeys.get("2", "") + elif key3_just_pressed and character_stats.spell_hotkeys.get("3", "") in character_stats.learnt_spells: + slot_pressed = "3" + spell_id = character_stats.spell_hotkeys.get("3", "") + if slot_pressed != "" and spell_id != "": var game_world = get_tree().get_first_node_in_group("game_world") var target_pos = Vector2.ZERO var heal_target: Node = null - if (is_fire or is_frost) and game_world and game_world.has_method("get_grid_locked_cursor_position"): + var is_heal = (spell_id == "healing") + if (spell_id == "flames" or spell_id == "frostspike" or spell_id == "earth_spike") and game_world and game_world.has_method("get_grid_locked_cursor_position"): target_pos = game_world.get_grid_locked_cursor_position() elif is_heal: heal_target = _get_heal_target() - - var has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) - var can_start_charge = is_heal or has_valid_target - # Reuse grabbable from single query above (avoids second get_overlapping_bodies) - var nearby_grabbable = nearby_grabbable_body - - if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object: + var has_valid_target = ((spell_id == "flames" or spell_id == "frostspike" or spell_id == "earth_spike") and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or (spell_id == "water_bubble") + var can_start = is_heal or has_valid_target + var mana_ok = (spell_id == "flames" or spell_id == "frostspike" or spell_id == "water_bubble" or spell_id == "earth_spike") and character_stats.mp >= 15.0 or (spell_id == "healing" and character_stats.mp >= 20.0) + if can_start and mana_ok and not nearby_grabbable_body and not is_lifting and not held_object: + spell_charge_hotkey_slot = slot_pressed + is_charging_spell = true + current_spell_element = "healing" if is_heal else ("frost" if spell_id == "frostspike" else ("water" if spell_id == "water_bubble" else ("earth" if spell_id == "earth_spike" else "fire"))) + spell_charge_start_time = Time.get_ticks_msec() / 1000.0 + spell_incantation_played = false + _start_spell_charge_particles() + _start_spell_charge_incantation() + if has_node("SfxSpellCharge"): + $SfxSpellCharge.play() + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_start.rpc() + elif not mana_ok and is_local_player: + _show_not_enough_mana_text() + + if character_stats and (spell_from_offhand or spell_from_hotkey): + var is_fire = false + var is_frost = false + var is_heal = false + var is_water_bubble = false + var is_earth_spike = false + if spell_charge_hotkey_slot != "": + var sid = character_stats.spell_hotkeys.get(spell_charge_hotkey_slot, "") + is_fire = (sid == "flames") + is_frost = (sid == "frostspike") + is_heal = (sid == "healing") + is_water_bubble = (sid == "water_bubble") + is_earth_spike = (sid == "earth_spike") + else: + is_fire = offhand_item.item_name == "Tome of Flames" + is_frost = offhand_item.item_name == "Tome of Frostspike" + is_heal = offhand_item.item_name == "Tome of Healing" + is_water_bubble = offhand_item.item_name == "Tome of Water Bubble" + is_earth_spike = offhand_item.item_name == "Tome of Earth Spike" + if is_fire or is_frost or is_heal or is_water_bubble or is_earth_spike: + var game_world = get_tree().get_first_node_in_group("game_world") + var target_pos = Vector2.ZERO + var heal_target: Node = null + if (is_fire or is_frost or is_earth_spike) and game_world and game_world.has_method("get_grid_locked_cursor_position"): + target_pos = game_world.get_grid_locked_cursor_position() + elif is_heal: + heal_target = _get_heal_target() + + var has_valid_target = ((is_fire or is_frost or is_earth_spike) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or is_water_bubble + var can_start_charge = is_heal or has_valid_target + # Reuse grabbable from single query above (avoids second get_overlapping_bodies) + var nearby_grabbable = nearby_grabbable_body + var hotkey_released = (spell_charge_hotkey_slot == "1" and key1_just_released) or (spell_charge_hotkey_slot == "2" and key2_just_released) or (spell_charge_hotkey_slot == "3" and key3_just_released) + + if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object: # Check if player has enough mana before starting to charge var has_enough_mana = false if character_stats: - if is_fire: - has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost - elif is_frost: - has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost + if is_fire or is_frost or is_water_bubble or is_earth_spike: + has_enough_mana = character_stats.mp >= 15.0 else: has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost @@ -2823,7 +2900,7 @@ func _handle_interactions(): return is_charging_spell = true - current_spell_element = "healing" if is_heal else ("frost" if is_frost else "fire") + current_spell_element = "healing" if is_heal else ("frost" if is_frost else ("water" if is_water_bubble else ("earth" if is_earth_spike else "fire"))) spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_incantation_played = false _start_spell_charge_particles() @@ -2834,94 +2911,11 @@ func _handle_interactions(): _sync_spell_charge_start.rpc() just_grabbed_this_frame = false return - elif grab_just_released and is_charging_spell: - var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time - if charge_time < 0.2: - is_charging_spell = false - current_spell_element = "fire" - spell_incantation_played = false - _stop_spell_charge_particles() - _stop_spell_charge_incantation() - _clear_spell_charge_tint() - _set_animation("IDLE") - if has_node("SfxSpellCharge"): - $SfxSpellCharge.stop() - if has_node("SfxSpellIncantation"): - $SfxSpellIncantation.stop() - if multiplayer.has_multiplayer_peer(): - _sync_spell_charge_end.rpc() - just_grabbed_this_frame = false - return - - var is_fully_charged = charge_time >= spell_charge_duration - if (is_fire or is_frost) and game_world and game_world.has_method("get_grid_locked_cursor_position"): - target_pos = game_world.get_grid_locked_cursor_position() - if is_heal: - heal_target = _get_heal_target() - has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) - - if has_valid_target and is_fully_charged: - # Check if player has enough mana before casting - var has_enough_mana = false - if character_stats: - if is_fire: - has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost - elif is_frost: - has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost - else: - has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost - - if has_enough_mana: - if is_fire: - _cast_flame_spell(target_pos) - elif is_frost: - _cast_frostspike_spell(target_pos) - else: - _cast_heal_spell(heal_target) - else: - is_charging_spell = false - current_spell_element = "fire" - spell_incantation_played = false - _stop_spell_charge_particles() - _stop_spell_charge_incantation() - _clear_spell_charge_tint() - _set_animation("IDLE") - if has_node("SfxSpellCharge"): - $SfxSpellCharge.stop() - if has_node("SfxSpellIncantation"): - $SfxSpellIncantation.stop() - if multiplayer.has_multiplayer_peer(): - _sync_spell_charge_end.rpc() - return - _set_animation("FINISH_SPELL") - movement_lock_timer = SPELL_CAST_LOCK_DURATION - is_charging_spell = false - current_spell_element = "fire" - spell_incantation_played = false - _stop_spell_charge_particles() - _stop_spell_charge_incantation() - _clear_spell_charge_tint() - if has_node("SfxSpellCharge"): - $SfxSpellCharge.stop() - else: - is_charging_spell = false - current_spell_element = "fire" - spell_incantation_played = false - _stop_spell_charge_particles() - _stop_spell_charge_incantation() - _clear_spell_charge_tint() - _set_animation("IDLE") - if has_node("SfxSpellCharge"): - $SfxSpellCharge.stop() - if has_node("SfxSpellIncantation"): - $SfxSpellIncantation.stop() - if multiplayer.has_multiplayer_peer(): - _sync_spell_charge_end.rpc() - just_grabbed_this_frame = false - return - elif is_charging_spell and (is_lifting or held_object or ((not is_heal) and not has_valid_target)): - # Don't cancel heal charge for no target (allow hover over enemy/wall); cancel fire/frost + elif (grab_just_released or hotkey_released) and is_charging_spell: + var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time + if charge_time < 0.2: is_charging_spell = false + spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() @@ -2934,7 +2928,103 @@ func _handle_interactions(): $SfxSpellIncantation.stop() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() - print(name, " spell charge cancelled (no target / lift / held)") + just_grabbed_this_frame = false + return + + var is_fully_charged = charge_time >= spell_charge_duration + if (is_fire or is_frost or is_earth_spike) and game_world and game_world.has_method("get_grid_locked_cursor_position"): + target_pos = game_world.get_grid_locked_cursor_position() + if is_heal: + heal_target = _get_heal_target() + has_valid_target = ((is_fire or is_frost or is_earth_spike) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or is_water_bubble + + if has_valid_target and is_fully_charged: + # Check if player has enough mana before casting + var has_enough_mana = false + if character_stats: + if is_fire or is_frost or is_water_bubble or is_earth_spike: + has_enough_mana = character_stats.mp >= 15.0 + else: + has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost + + if has_enough_mana: + if is_fire: + _cast_flame_spell(target_pos) + elif is_frost: + _cast_frostspike_spell(target_pos) + elif is_water_bubble: + var dir = Vector2.ZERO + if game_world and game_world.has_method("get_grid_locked_cursor_position"): + var cursor_pos = game_world.get_grid_locked_cursor_position() + dir = (cursor_pos - global_position).normalized() + if dir == Vector2.ZERO: + dir = Vector2.RIGHT.rotated(rotation) + _cast_water_bubble_spell(dir) + elif is_earth_spike: + _cast_earth_spike_spell(target_pos) + else: + _cast_heal_spell(heal_target) + else: + is_charging_spell = false + spell_charge_hotkey_slot = "" + current_spell_element = "fire" + spell_incantation_played = false + _stop_spell_charge_particles() + _stop_spell_charge_incantation() + _clear_spell_charge_tint() + _set_animation("IDLE") + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() + return + _set_animation("FINISH_SPELL") + movement_lock_timer = SPELL_CAST_LOCK_DURATION + is_charging_spell = false + spell_charge_hotkey_slot = "" + current_spell_element = "fire" + spell_incantation_played = false + _stop_spell_charge_particles() + _stop_spell_charge_incantation() + _clear_spell_charge_tint() + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + else: + is_charging_spell = false + spell_charge_hotkey_slot = "" + current_spell_element = "fire" + spell_incantation_played = false + _stop_spell_charge_particles() + _stop_spell_charge_incantation() + _clear_spell_charge_tint() + _set_animation("IDLE") + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() + just_grabbed_this_frame = false + return + elif is_charging_spell and (is_lifting or held_object or ((not is_heal) and not has_valid_target)): + # Don't cancel heal charge for no target (allow hover over enemy/wall); cancel fire/frost + is_charging_spell = false + spell_charge_hotkey_slot = "" + current_spell_element = "fire" + spell_incantation_played = false + _stop_spell_charge_particles() + _stop_spell_charge_incantation() + _clear_spell_charge_tint() + _set_animation("IDLE") + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() + print(name, " spell charge cancelled (no target / lift / held)") # Check for bomb usage (if bomb equipped in offhand) # Also check if we're already holding a bomb - if so, skip normal grab handling @@ -2945,8 +3035,8 @@ func _handle_interactions(): is_holding_bomb = true if character_stats and character_stats.equipment.has("offhand"): - var offhand_item = character_stats.equipment["offhand"] - if offhand_item and offhand_item.weapon_type == Item.WeaponType.BOMB and offhand_item.quantity > 0: + var offhand_equipped = character_stats.equipment["offhand"] + if offhand_equipped and offhand_equipped.weapon_type == Item.WeaponType.BOMB and offhand_equipped.quantity > 0: # Check if there's a grabbable object nearby - prioritize grabbing over bomb var nearby_grabbable = null if grab_area: @@ -2981,9 +3071,9 @@ func _handle_interactions(): else: # Human/Elf: Throw bomb or drop next to player # Consume one bomb - offhand_item.quantity -= 1 - var remaining = offhand_item.quantity - if offhand_item.quantity <= 0: + offhand_equipped.quantity -= 1 + var remaining = offhand_equipped.quantity + if offhand_equipped.quantity <= 0: character_stats.equipment["offhand"] = null if character_stats: character_stats.character_changed.emit(character_stats) @@ -4596,6 +4686,74 @@ func _sync_frostspike_spell(target_position: Vector2, spell_damage: float): get_parent().add_child(frost) print(name, " (synced) spawned frostspike at ", target_position) +func _cast_water_bubble_spell(direction: Vector2): + if not water_bubble_spell_scene or not is_multiplayer_authority(): + return + const MANA_COST = 15.0 + if not character_stats or not character_stats.use_mana(MANA_COST): + if is_local_player: + _show_not_enough_mana_text() + return + var spell_damage = 15.0 + if character_stats: + spell_damage = character_stats.damage * 0.75 + var bubble = water_bubble_spell_scene.instantiate() + bubble.setup(global_position, direction, self, spell_damage) + get_parent().add_child(bubble) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_water_bubble_spell", [direction, spell_damage]) + print(name, " cast water bubble") + +@rpc("any_peer", "reliable") +func _sync_water_bubble_spell(direction: Vector2, spell_damage: float): + if is_multiplayer_authority(): + return + if not water_bubble_spell_scene: + return + var bubble = water_bubble_spell_scene.instantiate() + bubble.setup(global_position, direction, self, spell_damage) + get_parent().add_child(bubble) + print(name, " (synced) spawned water bubble") + +func _cast_earth_spike_spell(target_position: Vector2): + if not earth_spike_spell_scene or not is_multiplayer_authority(): + return + const MANA_COST = 15.0 + if not character_stats or not character_stats.use_mana(MANA_COST): + if is_local_player: + _show_not_enough_mana_text() + return + var game_world = get_tree().get_first_node_in_group("game_world") + var valid_target_pos = target_position + if game_world and game_world.has_method("_get_valid_spell_target_position"): + var found_pos = game_world._get_valid_spell_target_position(target_position) + if found_pos != Vector2.ZERO: + valid_target_pos = found_pos + else: + if character_stats: + character_stats.restore_mana(MANA_COST) + return + var spell_damage = 15.0 + if character_stats: + spell_damage = character_stats.damage * 0.75 + var earth = earth_spike_spell_scene.instantiate() + earth.setup(valid_target_pos, self, spell_damage, true) + get_parent().add_child(earth) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_earth_spike_spell", [valid_target_pos, spell_damage]) + print(name, " cast earth spike at ", valid_target_pos) + +@rpc("any_peer", "reliable") +func _sync_earth_spike_spell(target_position: Vector2, spell_damage: float): + if is_multiplayer_authority(): + return + if not earth_spike_spell_scene: + return + var earth = earth_spike_spell_scene.instantiate() + earth.setup(target_position, self, spell_damage, true) + get_parent().add_child(earth) + print(name, " (synced) spawned earth spike at ", target_position) + func _cast_heal_spell(target: Node): if not target or not is_instance_valid(target): return @@ -4926,9 +5084,9 @@ func _stop_spell_charge_particles(): func _start_spell_charge_incantation(): spell_incantation_fire_ready_shown = false if has_node("AnimationIncantation"): - if _is_healing_spell(): + if _is_healing_spell() or current_spell_element == "healing": $AnimationIncantation.play("healing_charging") - elif _is_frost_spell(): + elif _is_frost_spell() or current_spell_element == "frost" or current_spell_element == "water": $AnimationIncantation.play("frost_charging") else: $AnimationIncantation.play("fire_charging") @@ -4937,9 +5095,9 @@ func _update_spell_charge_incantation(charge_progress: float): if not has_node("AnimationIncantation"): return if charge_progress >= 1.0 and not spell_incantation_fire_ready_shown: - if _is_healing_spell(): + if _is_healing_spell() or current_spell_element == "healing": $AnimationIncantation.play("healing_ready") - elif _is_frost_spell(): + elif _is_frost_spell() or current_spell_element == "frost" or current_spell_element == "water": $AnimationIncantation.play("frost_ready") else: $AnimationIncantation.play("fire_ready") @@ -4955,10 +5113,14 @@ func _apply_spell_charge_tint(): if not is_charging_spell: return var tint = spell_charge_tint - if _is_healing_spell(): + if _is_healing_spell() or current_spell_element == "healing": tint = Color(0.25, 2.0, 0.3, 2.0) # Green pulse for healing - elif _is_frost_spell(): + elif _is_frost_spell() or current_spell_element == "frost": tint = Color(0.25, 0.5, 2.0, 2.0) # Blue pulse for frost + elif current_spell_element == "water": + tint = Color(0.25, 0.6, 2.0, 2.0) # Blue pulse for water bubble + elif current_spell_element == "earth": + tint = Color(0.9, 0.55, 0.2, 2.0) # Brown/orange pulse for earth spike var sprites = [ {"sprite": sprite_body, "name": "body"}, {"sprite": sprite_boots, "name": "boots"}, @@ -5196,6 +5358,7 @@ func _sync_spell_charge_start(): func _sync_spell_charge_end(): if not is_multiplayer_authority(): is_charging_spell = false + spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() @@ -6421,6 +6584,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool if should_cancel: is_charging_spell = false + spell_charge_hotkey_slot = "" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() @@ -7277,15 +7441,13 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): "Human": character_stats.setEars(0) - # Give Human (Wizard) starting tomes and hat to remote players ONLY when slots are null (initial sync) - # Never overwrite existing equipment - preserves loadout across level transitions + # Give Human (Wizard) starting tomes and hat to remote players ONLY when headgear empty (initial sync) if not is_multiplayer_authority(): - var offhand_empty = character_stats.equipment["offhand"] == null var headgear_empty = character_stats.equipment["headgear"] == null - if offhand_empty and headgear_empty: + if headgear_empty: var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: - character_stats.equipment["offhand"] = starting_tome + character_stats.add_item(starting_tome) var tome_healing = ItemDatabase.create_item("tome_of_healing") if tome_healing: character_stats.add_item(tome_healing) diff --git a/src/scripts/spell_book_ui.gd b/src/scripts/spell_book_ui.gd new file mode 100644 index 0000000..5956bd9 --- /dev/null +++ b/src/scripts/spell_book_ui.gd @@ -0,0 +1,823 @@ +extends Control +## Spell Book UI: book that opens (cover folds back), then pages flip to reveal the spell spread. +## Beautiful Bezier spline-based page fold animation with shadow, back-face, and highlight layers. +## Assign spells to hotkey 1/2/3. + +# ── Layout constants ────────────────────────────────────────────── +const SPELLS_PER_PAGE: int = 2 +const SPINE_WIDTH: int = 14 +const SPREAD_WIDTH: int = 420 +const BOOK_HEIGHT: int = 300 +const PAGE_WIDTH: int = 210 + +# ── Animation timing ───────────────────────────────────────────── +const COVER_SQUEEZE_DURATION: float = 0.7 # horizontal squeeze open +const COVER_PAUSE_BEFORE_FLIPS: float = 0.15 # pause before cascade +const OPENING_PAGE_FLIPS: int = 22 +const FLIP_MIN_DURATION: float = 0.05 # fastest (mid-cascade) +const FLIP_MAX_DURATION: float = 0.12 # slowest (start/end) +const PAGE_TURN_DURATION: float = 0.35 # manual prev/next turn +const CONTENT_FADE_DURATION: float = 0.3 + +# ── Bezier fold geometry ───────────────────────────────────────── +const BEZIER_SAMPLES: int = 24 # vertices along fold curve +const FOLD_CURVE_MAX: float = 35.0 # primary outward bow (px) +const FOLD_CURVE_SECONDARY: float = 12.0 # secondary S-curve counter-bow +const FOLD_CURVE_RANDOMIZE: float = 0.15 # +/- per-flip variation + +# ── Visual layers ──────────────────────────────────────────────── +const PAGE_COLOR_A: Color = Color(0.96, 0.93, 0.84) # warm ivory +const PAGE_COLOR_B: Color = Color(0.94, 0.91, 0.86) # cool parchment +const BACK_FACE_DARKEN: float = 0.88 +const SHADOW_OFFSET: Vector2 = Vector2(4, 6) +const SHADOW_ALPHA: float = 0.25 +const HIGHLIGHT_COLOR: Color = Color(1.0, 1.0, 0.96, 0.45) +const HIGHLIGHT_WIDTH: float = 1.5 + +# ── Spell icons from shade_spell_effects.png (105 h × 79 v) ───── +const SPELL_ICON_TEXTURE_PATH: String = "res://assets/gfx/fx/shade_spell_effects.png" +const SPELL_ICON_HFRAMES: int = 105 +const SPELL_ICON_VFRAMES: int = 79 +const SPELL_ICON_FRAMES: Dictionary = { + "flames": 2244, "frostspike": 4410, "healing": 691, "water_bubble": 2689, "earth_spike": 3737 +} +const SPELL_INFO: Dictionary = { + "flames": ["Flames", "Hurl a ball of fire at a target. Costs 15 mana."], + "frostspike": ["Frost Spike", "Launch an icy spike at a target. Costs 15 mana."], + "healing": ["Healing", "Heal an ally. Costs 20 mana."], + "water_bubble": ["Water Bubble", "Shoot a bouncing water bubble. Pops on contact or after 5s. Costs 15 mana."], + "earth_spike": ["Earth Spike", "Launch an earth spike at a target. Costs 15 mana."] +} + +# Texture paths +var _cover_texture_paths: Array = [ + "res://assets/gfx/ui/spellbook_cover.png", + "res://assets/spellbook_cover.png" +] +const PAGE_PARCHMENT_PATH: String = "res://assets/gfx/ui/page_parchment.png" +const SPINE_LEATHER_PATH: String = "res://assets/gfx/ui/spine_leather.png" + +# State +var character_stats: CharacterStats = null +var current_page: int = 0 +var selected_spell_index: int = -1 + +# UI nodes +var _book_wrapper: Control = null +var _book_root: Control = null +var _cover: Control = null +var _spread: Control = null +var _left_page: Control = null +var _right_page: Control = null +var _left_entry: Control = null +var _right_entry: Control = null +var _prev_btn: Button = null +var _next_btn: Button = null +var _hint_label: Label = null +var _spine_rect: Control = null +var _btn_hbox: HBoxContainer = null + +# Flip animation state +var _flip_overlay: Control = null +var _flip_front: Polygon2D = null +var _flip_back: Polygon2D = null +var _flip_shadow: Polygon2D = null +var _flip_highlight: Line2D = null +var _flip_hinge_left: bool = true +var _flip_curve_amount: float = FOLD_CURVE_MAX +var _play_cover_open: bool = true +var _flip_tween: Tween = null +var _opening_flip_index: int = 0 +var _opening_flip_tween: Tween = null + + +# ══════════════════════════════════════════════════════════════════ +# Lifecycle +# ══════════════════════════════════════════════════════════════════ + +func _ready(): + _build_book_ui() + _fit_book_in_view() + if _book_wrapper: + _book_wrapper.resized.connect(_fit_book_in_view) + resized.connect(_fit_book_in_view) + +func _notification(what: int): + if what == NOTIFICATION_RESIZED: + _fit_book_in_view() + if what == NOTIFICATION_VISIBILITY_CHANGED: + if visible: + _play_cover_open = true + call_deferred("_fit_book_in_view") + else: + # Reset to closed state so next open replays full animation + _play_cover_open = true + _kill_all_tweens() + _cleanup_flip_overlay() + if _cover: + _cover.set_position(Vector2.ZERO) + _cover.scale = Vector2(1.0, 1.0) + _cover.modulate.a = 1.0 + _cover.visible = true + if _spread: + _spread.visible = true + if _left_page and is_instance_valid(_left_page): + _left_page.modulate.a = 1.0 + if _right_page and is_instance_valid(_right_page): + _right_page.modulate.a = 1.0 + if _btn_hbox and is_instance_valid(_btn_hbox): + _btn_hbox.modulate.a = 1.0 + if _hint_label and is_instance_valid(_hint_label): + _hint_label.modulate.a = 1.0 + +func _input(event): + if not visible or not character_stats: + return + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_1 or event.keycode == KEY_KP_1: + _assign_to_slot("1") + get_viewport().set_input_as_handled() + elif event.keycode == KEY_2 or event.keycode == KEY_KP_2: + _assign_to_slot("2") + get_viewport().set_input_as_handled() + elif event.keycode == KEY_3 or event.keycode == KEY_KP_3: + _assign_to_slot("3") + get_viewport().set_input_as_handled() + + +# ══════════════════════════════════════════════════════════════════ +# Public API +# ══════════════════════════════════════════════════════════════════ + +func set_character_stats(cs: CharacterStats): + character_stats = cs + refresh() + +func refresh(): + if not is_node_ready(): + return + if not character_stats: + _hide_spread() + return + var learnt: Array = character_stats.learnt_spells + var total_pages_val = maxi(1, int(float(learnt.size() + SPELLS_PER_PAGE - 1) / float(SPELLS_PER_PAGE))) + current_page = clampi(current_page, 0, total_pages_val - 1) + selected_spell_index = -1 + _update_page_content() + _update_nav_buttons() + if _hint_label: + _hint_label.text = "Select a spell and press 1, 2, or 3 to assign to hotkey." + if visible and _play_cover_open and _cover and _spread: + _start_cover_open() + + +# ══════════════════════════════════════════════════════════════════ +# Bezier helpers +# ══════════════════════════════════════════════════════════════════ + +func _cubic_bezier(p0: Vector2, p1: Vector2, p2: Vector2, p3: Vector2, t: float) -> Vector2: + ## Evaluate cubic Bezier curve at parameter t in [0, 1]. + var u: float = 1.0 - t + var u2: float = u * u + var t2: float = t * t + return u2 * u * p0 + 3.0 * u2 * t * p1 + 3.0 * u * t2 * p2 + t2 * t * p3 + +func _sample_fold_curve(fold_x: float, curve_amount: float, secondary: float) -> PackedVector2Array: + ## Sample the fold Bezier from top to bottom of the page, returning + ## BEZIER_SAMPLES+1 points that define the fold line with an S-curve bow. + var h: float = float(BOOK_HEIGHT) + var p0: Vector2 = Vector2(fold_x, 0.0) + var p1: Vector2 = Vector2(fold_x - curve_amount, h * 0.25) # primary bow + var p2: Vector2 = Vector2(fold_x + secondary, h * 0.75) # counter-bow + var p3: Vector2 = Vector2(fold_x, h) + var pts: PackedVector2Array = PackedVector2Array() + pts.resize(BEZIER_SAMPLES + 1) + for i in range(BEZIER_SAMPLES + 1): + var bt: float = float(i) / float(BEZIER_SAMPLES) + pts[i] = _cubic_bezier(p0, p1, p2, p3, bt) + return pts + +func _build_front_polygon(fold_pts: PackedVector2Array, hinge_left: bool) -> PackedVector2Array: + ## Polygon for the still-flat portion of the page (the part not yet turned). + var w: float = float(PAGE_WIDTH) + var h: float = float(BOOK_HEIGHT) + var pts: PackedVector2Array = PackedVector2Array() + if hinge_left: + # Front = left of fold: top-left corner → along fold top→bottom → bottom-left corner + pts.append(Vector2(0, 0)) + for p in fold_pts: + pts.append(p) + pts.append(Vector2(0, h)) + else: + # Front = right of fold: fold top→bottom → bottom-right → top-right + for i in range(fold_pts.size()): + pts.append(fold_pts[i]) + pts.append(Vector2(w, h)) + pts.append(Vector2(w, 0)) + return pts + +func _build_back_polygon(fold_pts: PackedVector2Array, hinge_left: bool, progress: float) -> PackedVector2Array: + ## Polygon for the back face (folded-over sliver) that peeks out on the + ## opposite side of the fold line, creating the illusion of page thickness. + var w: float = float(PAGE_WIDTH) + var pts: PackedVector2Array = PackedVector2Array() + # Sliver width peaks at mid-turn and tapers toward page edges + var max_sliver: float = w * 0.35 * sin(progress * PI) + if max_sliver < 1.0: + return pts + if hinge_left: + # Back face extends to the RIGHT of the fold line + for fp in fold_pts: + pts.append(fp) + for i in range(fold_pts.size() - 1, -1, -1): + var fp: Vector2 = fold_pts[i] + var edge_taper: float = 1.0 - abs(2.0 * float(i) / float(BEZIER_SAMPLES) - 1.0) * 0.4 + pts.append(Vector2(minf(fp.x + max_sliver * edge_taper, w), fp.y)) + else: + # Back face extends to the LEFT of the fold line + for fp in fold_pts: + pts.append(fp) + for i in range(fold_pts.size() - 1, -1, -1): + var fp: Vector2 = fold_pts[i] + var edge_taper: float = 1.0 - abs(2.0 * float(i) / float(BEZIER_SAMPLES) - 1.0) * 0.4 + pts.append(Vector2(maxf(fp.x - max_sliver * edge_taper, 0.0), fp.y)) + return pts + +func _build_shadow_polygon(fold_pts: PackedVector2Array, hinge_left: bool, progress: float) -> PackedVector2Array: + ## Shadow polygon offset from the fold, cast onto the underlying page. + var w: float = float(PAGE_WIDTH) + var shadow_width: float = 8.0 + 14.0 * sin(progress * PI) + var pts: PackedVector2Array = PackedVector2Array() + if hinge_left: + # Shadow falls LEFT of the fold (onto the still-flat page) + for fp in fold_pts: + pts.append(Vector2(fp.x + SHADOW_OFFSET.x, fp.y + SHADOW_OFFSET.y)) + for i in range(fold_pts.size() - 1, -1, -1): + var fp: Vector2 = fold_pts[i] + pts.append(Vector2(maxf(fp.x - shadow_width + SHADOW_OFFSET.x, 0.0), fp.y + SHADOW_OFFSET.y)) + else: + # Shadow falls RIGHT of the fold + for fp in fold_pts: + pts.append(Vector2(fp.x + SHADOW_OFFSET.x, fp.y + SHADOW_OFFSET.y)) + for i in range(fold_pts.size() - 1, -1, -1): + var fp: Vector2 = fold_pts[i] + pts.append(Vector2(minf(fp.x + shadow_width + SHADOW_OFFSET.x, w), fp.y + SHADOW_OFFSET.y)) + return pts + + +# ══════════════════════════════════════════════════════════════════ +# Cover opening (horizontal squeeze animation) +# ══════════════════════════════════════════════════════════════════ + +func _start_cover_open(): + _play_cover_open = false + # Reset cover to closed state (full size, fully visible) + _cover.visible = true + _cover.set_position(Vector2.ZERO) + _cover.scale = Vector2(1.0, 1.0) + _cover.modulate.a = 1.0 + _cover.pivot_offset = Vector2(0, BOOK_HEIGHT / 2.0) # anchor at left (spine) edge + _spread.visible = true + # Hide content + controls during opening animation + _left_page.modulate.a = 0.0 + _right_page.modulate.a = 0.0 + if _btn_hbox: + _btn_hbox.modulate.a = 0.0 + if _hint_label: + _hint_label.modulate.a = 0.0 + _kill_all_tweens() + _flip_tween = create_tween() + # ── Squeeze cover horizontally toward spine (simulates folding open) ── + _flip_tween.set_ease(Tween.EASE_IN_OUT) + _flip_tween.set_trans(Tween.TRANS_CUBIC) + _flip_tween.set_parallel(true) + _flip_tween.tween_property(_cover, "scale:x", 0.0, COVER_SQUEEZE_DURATION) + _flip_tween.tween_property(_cover, "modulate:a", 0.0, COVER_SQUEEZE_DURATION * 0.6).set_delay(COVER_SQUEEZE_DURATION * 0.4) + _flip_tween.set_parallel(false) + _flip_tween.tween_callback(func(): _cover.visible = false) + # ── Brief pause before page flips ── + _flip_tween.tween_interval(COVER_PAUSE_BEFORE_FLIPS) + _flip_tween.tween_callback(_on_cover_open_finished) + + +# ══════════════════════════════════════════════════════════════════ +# 4-layer flip overlay +# ══════════════════════════════════════════════════════════════════ + +func _create_flip_overlay(at_right_page: bool, curve_override: float = -1.0) -> void: + var spread_x: int = SPINE_WIDTH + _flip_hinge_left = at_right_page + _flip_curve_amount = curve_override if curve_override > 0.0 else FOLD_CURVE_MAX + _cleanup_flip_overlay() + # Container for all flip layers (clipped to page bounds) + _flip_overlay = Control.new() + _flip_overlay.set_position(Vector2(spread_x + (PAGE_WIDTH if at_right_page else 0), 0)) + _flip_overlay.set_size(Vector2(PAGE_WIDTH, BOOK_HEIGHT)) + _flip_overlay.custom_minimum_size = Vector2(PAGE_WIDTH, BOOK_HEIGHT) + _flip_overlay.clip_contents = true + _book_root.add_child(_flip_overlay) + _flip_overlay.z_index = 10 + # Layer 1 (bottom): Shadow + _flip_shadow = Polygon2D.new() + _flip_shadow.color = Color(0.0, 0.0, 0.0, SHADOW_ALPHA) + _flip_shadow.z_index = 0 + _flip_shadow.visible = false + _flip_overlay.add_child(_flip_shadow) + # Layer 2: Back face (slightly darker page) + _flip_back = Polygon2D.new() + var back_c: Color = PAGE_COLOR_A * BACK_FACE_DARKEN + back_c.a = 1.0 + _flip_back.color = back_c + _flip_back.z_index = 1 + _flip_back.visible = false + _flip_overlay.add_child(_flip_back) + # Layer 3: Front face (main visible page) + _flip_front = Polygon2D.new() + _flip_front.color = PAGE_COLOR_A + _flip_front.z_index = 2 + _flip_overlay.add_child(_flip_front) + # Layer 4 (top): Highlight gleam along fold edge + _flip_highlight = Line2D.new() + _flip_highlight.default_color = HIGHLIGHT_COLOR + _flip_highlight.width = HIGHLIGHT_WIDTH + _flip_highlight.z_index = 3 + _flip_highlight.visible = false + _flip_overlay.add_child(_flip_highlight) + _set_page_fold(0.0) + +func _set_page_fold(t: float) -> void: + ## Update all 4 polygon layers for fold progress t (0 = flat, 1 = fully turned). + if not _flip_front or not is_instance_valid(_flip_front): + return + var w: float = float(PAGE_WIDTH) + # Fold line X: sweeps across the page + var fold_x: float + if _flip_hinge_left: + fold_x = w * (1.0 - t) # right → left + else: + fold_x = w * t # left → right + # Dynamic curve amount peaking at mid-turn + var curve_envelope: float = sin(t * PI) + var primary: float = _flip_curve_amount * curve_envelope + var secondary: float = FOLD_CURVE_SECONDARY * curve_envelope + # Flip curve direction for right-hinged pages + if not _flip_hinge_left: + primary = -primary + secondary = -secondary + # Sample the Bezier S-curve + var fold_pts: PackedVector2Array = _sample_fold_curve(fold_x, primary, secondary) + # ── Front face ── + _flip_front.polygon = _build_front_polygon(fold_pts, _flip_hinge_left) + # ── Back face (only visible mid-turn) ── + if _flip_back and is_instance_valid(_flip_back): + if t > 0.03 and t < 0.97: + var back_pts: PackedVector2Array = _build_back_polygon(fold_pts, _flip_hinge_left, t) + if back_pts.size() >= 3: + _flip_back.polygon = back_pts + _flip_back.visible = true + else: + _flip_back.visible = false + else: + _flip_back.visible = false + # ── Shadow ── + if _flip_shadow and is_instance_valid(_flip_shadow): + if t > 0.03 and t < 0.97: + _flip_shadow.polygon = _build_shadow_polygon(fold_pts, _flip_hinge_left, t) + _flip_shadow.visible = true + else: + _flip_shadow.visible = false + # ── Highlight along fold edge ── + if _flip_highlight and is_instance_valid(_flip_highlight): + if t > 0.03 and t < 0.97: + _flip_highlight.points = fold_pts + _flip_highlight.visible = true + else: + _flip_highlight.visible = false + + +# ══════════════════════════════════════════════════════════════════ +# Opening cascade (22 page flips with variable timing) +# ══════════════════════════════════════════════════════════════════ + +func _on_cover_open_finished(): + _opening_flip_index = 0 + _run_next_opening_flip() + +func _get_flip_duration(index: int) -> float: + ## Sine-based duration curve: slow at edges, fast in the middle. + var t: float = float(index) / float(maxi(OPENING_PAGE_FLIPS - 1, 1)) + var speed_factor: float = sin(t * PI) # 0 at start/end, 1 at middle + return lerpf(FLIP_MAX_DURATION, FLIP_MIN_DURATION, speed_factor) + +func _run_next_opening_flip(): + if _opening_flip_index >= OPENING_PAGE_FLIPS: + _on_opening_flips_finished() + return + var flip_right: bool = (_opening_flip_index % 2 == 0) + # Slight random curve variation per page for organic feel + var base_variation: float = FOLD_CURVE_MAX * FOLD_CURVE_RANDOMIZE + var curve: float = FOLD_CURVE_MAX + randf_range(-base_variation, base_variation) + _create_flip_overlay(flip_right, curve) + # Alternate page color slightly between flips + if _flip_front and is_instance_valid(_flip_front): + _flip_front.color = PAGE_COLOR_B if (_opening_flip_index % 2 == 1) else PAGE_COLOR_A + var duration: float = _get_flip_duration(_opening_flip_index) + if _opening_flip_tween and _opening_flip_tween.is_valid(): + _opening_flip_tween.kill() + _opening_flip_tween = create_tween() + _opening_flip_tween.set_ease(Tween.EASE_IN_OUT) + _opening_flip_tween.set_trans(Tween.TRANS_SINE) + _opening_flip_tween.tween_method(_set_page_fold, 0.0, 1.0, duration) + _opening_flip_tween.tween_callback(_on_single_opening_flip_done) + +func _on_single_opening_flip_done(): + _opening_flip_index += 1 + _cleanup_flip_overlay() + _run_next_opening_flip() + +func _on_opening_flips_finished(): + _cleanup_flip_overlay() + # Smoothly fade in spell content and controls + var fade_tween: Tween = create_tween() + fade_tween.set_ease(Tween.EASE_OUT) + fade_tween.set_trans(Tween.TRANS_CUBIC) + fade_tween.set_parallel(true) + fade_tween.tween_property(_left_page, "modulate:a", 1.0, CONTENT_FADE_DURATION) + fade_tween.tween_property(_right_page, "modulate:a", 1.0, CONTENT_FADE_DURATION) + if _btn_hbox: + fade_tween.tween_property(_btn_hbox, "modulate:a", 1.0, CONTENT_FADE_DURATION) + if _hint_label: + fade_tween.tween_property(_hint_label, "modulate:a", 1.0, CONTENT_FADE_DURATION) + + +# ══════════════════════════════════════════════════════════════════ +# Utility +# ══════════════════════════════════════════════════════════════════ + +func _kill_all_tweens(): + if _flip_tween and _flip_tween.is_valid(): + _flip_tween.kill() + if _opening_flip_tween and _opening_flip_tween.is_valid(): + _opening_flip_tween.kill() + +func _cleanup_flip_overlay(): + if _flip_overlay and is_instance_valid(_flip_overlay): + _flip_overlay.queue_free() + _flip_overlay = null + _flip_front = null + _flip_back = null + _flip_shadow = null + _flip_highlight = null + +func _update_nav_buttons(): + var learnt: Array = character_stats.learnt_spells if character_stats else [] + var total_pages_val = maxi(1, int(float(learnt.size() + SPELLS_PER_PAGE - 1) / float(SPELLS_PER_PAGE))) + if _prev_btn: + _prev_btn.visible = total_pages_val > 1 + _prev_btn.disabled = (current_page <= 0) + if _next_btn: + _next_btn.visible = total_pages_val > 1 + _next_btn.disabled = (current_page >= total_pages_val - 1) + + +# ══════════════════════════════════════════════════════════════════ +# Spell slot assignment +# ══════════════════════════════════════════════════════════════════ + +func _assign_to_slot(slot: String): + if not character_stats or selected_spell_index < 0: + return + var learnt: Array = character_stats.learnt_spells + var idx = current_page * SPELLS_PER_PAGE + selected_spell_index + if idx < 0 or idx >= learnt.size(): + return + var spell_id: String = learnt[idx] + character_stats.spell_hotkeys[slot] = spell_id + character_stats.emit_signal("character_changed", character_stats) + _update_page_content() + + +# ══════════════════════════════════════════════════════════════════ +# Layout fitting +# ══════════════════════════════════════════════════════════════════ + +func _fit_book_in_view() -> void: + ## Scale and center book inside the panel so it doesn't push the inventory frame. + if not _book_wrapper or not _book_root or not is_instance_valid(_book_root): + return + var total_width: int = SPINE_WIDTH + SPREAD_WIDTH + var w: float = _book_wrapper.size.x + var h: float = _book_wrapper.size.y + if w <= 0 or h <= 0: + return + var scale_x: float = w / float(total_width) + var scale_y: float = h / float(BOOK_HEIGHT) + var s: float = minf(scale_x, scale_y) * 0.9 + _book_root.scale = Vector2(s, s) + _book_root.position = Vector2((w - total_width * s) * 0.5, (h - BOOK_HEIGHT * s) * 0.5) + _book_root.size = Vector2(total_width, BOOK_HEIGHT) + + +# ══════════════════════════════════════════════════════════════════ +# Page navigation +# ══════════════════════════════════════════════════════════════════ + +func _on_prev(): + if _flip_tween and _flip_tween.is_valid(): + return + if _opening_flip_tween and _opening_flip_tween.is_valid(): + return + if current_page <= 0: + return + current_page = maxi(0, current_page - 1) + selected_spell_index = -1 + _update_page_content() + _update_nav_buttons() + _left_page.modulate.a = 0.0 + _play_page_turn(true) + +func _on_next(): + if _flip_tween and _flip_tween.is_valid(): + return + if _opening_flip_tween and _opening_flip_tween.is_valid(): + return + var learnt: Array = character_stats.learnt_spells if character_stats else [] + var total_pages_val = maxi(1, int(float(learnt.size() + SPELLS_PER_PAGE - 1) / float(SPELLS_PER_PAGE))) + if current_page >= total_pages_val - 1: + return + current_page = mini(current_page + 1, total_pages_val - 1) + selected_spell_index = -1 + _right_page.modulate.a = 0.0 + _update_page_content() + _update_nav_buttons() + _play_page_turn(false) + +func _play_page_turn(is_prev: bool): + _create_flip_overlay(not is_prev) + if _flip_tween and _flip_tween.is_valid(): + _flip_tween.kill() + _flip_tween = create_tween() + _flip_tween.set_ease(Tween.EASE_IN_OUT) + _flip_tween.set_trans(Tween.TRANS_SINE) + _flip_tween.tween_method(_set_page_fold, 0.0, 1.0, PAGE_TURN_DURATION) + _flip_tween.tween_callback(_on_page_turn_finished.bind(is_prev)) + +func _on_page_turn_finished(_is_prev: bool): + _cleanup_flip_overlay() + _right_page.modulate.a = 1.0 + _left_page.modulate.a = 1.0 + + +# ══════════════════════════════════════════════════════════════════ +# UI construction +# ══════════════════════════════════════════════════════════════════ + +func _build_book_ui(): + var vbox = VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + vbox.add_theme_constant_override("separation", 6) + add_child(vbox) + # Wrapper: clips and takes allocated space + _book_wrapper = Control.new() + _book_wrapper.custom_minimum_size = Vector2(0, 200) + _book_wrapper.size_flags_vertical = Control.SIZE_EXPAND_FILL + _book_wrapper.clip_contents = true + vbox.add_child(_book_wrapper) + # Book layout: [Spine | Left Page | Right Page] + var total_width: int = SPINE_WIDTH + SPREAD_WIDTH + _book_root = Control.new() + _book_root.custom_minimum_size = Vector2(0, 0) + _book_root.set_size(Vector2(total_width, BOOK_HEIGHT)) + _book_root.set_position(Vector2.ZERO) + _book_wrapper.add_child(_book_root) + # Spine (leftmost) + _spine_rect = TextureRect.new() + var spine_tex: Texture2D = load(SPINE_LEATHER_PATH) as Texture2D + if spine_tex: + _spine_rect.texture = spine_tex + _spine_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _spine_rect.stretch_mode = TextureRect.STRETCH_SCALE + _spine_rect.set_position(Vector2(0, 0)) + _spine_rect.set_size(Vector2(SPINE_WIDTH, BOOK_HEIGHT)) + _spine_rect.custom_minimum_size = Vector2(SPINE_WIDTH, BOOK_HEIGHT) + _book_root.add_child(_spine_rect) + # Spread (pages immediately right of spine) + _spread = HBoxContainer.new() + _spread.set_position(Vector2(SPINE_WIDTH, 0)) + _spread.custom_minimum_size = Vector2(SPREAD_WIDTH, BOOK_HEIGHT) + _spread.set_size(Vector2(SPREAD_WIDTH, BOOK_HEIGHT)) + _spread.add_theme_constant_override("separation", 0) + _book_root.add_child(_spread) + # Cover (overlay on top of everything, full book width) + _cover = _make_cover() + _cover.set_position(Vector2.ZERO) + var cover_w: int = SPINE_WIDTH + SPREAD_WIDTH + _cover.custom_minimum_size = Vector2(cover_w, BOOK_HEIGHT) + _cover.set_size(Vector2(cover_w, BOOK_HEIGHT)) + _cover.clip_contents = true # clip STRETCH_KEEP_ASPECT_COVERED overflow + _cover.pivot_offset = Vector2(0, BOOK_HEIGHT / 2.0) # anchor squeeze at left (spine) edge + _cover.z_index = 5 # above spine and spread + _cover.visible = true + _book_root.add_child(_cover) + # Left page + _left_page = _make_page_panel() + _left_page.custom_minimum_size = Vector2(PAGE_WIDTH, BOOK_HEIGHT) + _spread.add_child(_left_page) + # Right page + _right_page = _make_page_panel() + _right_page.custom_minimum_size = Vector2(PAGE_WIDTH, BOOK_HEIGHT) + _spread.add_child(_right_page) + # Left spell entry (with margins) + var margin_l = MarginContainer.new() + margin_l.add_theme_constant_override("margin_left", 6) + margin_l.add_theme_constant_override("margin_right", 6) + margin_l.add_theme_constant_override("margin_top", 6) + margin_l.add_theme_constant_override("margin_bottom", 6) + _left_entry = _create_spell_entry(0) + margin_l.add_child(_left_entry) + _left_page.add_child(margin_l) + # Right spell entry (with margins) + var margin_r = MarginContainer.new() + margin_r.add_theme_constant_override("margin_left", 6) + margin_r.add_theme_constant_override("margin_right", 6) + margin_r.add_theme_constant_override("margin_top", 6) + margin_r.add_theme_constant_override("margin_bottom", 6) + _right_entry = _create_spell_entry(1) + margin_r.add_child(_right_entry) + _right_page.add_child(margin_r) + # Navigation buttons + _btn_hbox = HBoxContainer.new() + _btn_hbox.add_theme_constant_override("separation", 8) + _prev_btn = Button.new() + _prev_btn.text = "◀ Prev" + _prev_btn.pressed.connect(_on_prev) + _btn_hbox.add_child(_prev_btn) + _next_btn = Button.new() + _next_btn.text = "Next ▶" + _next_btn.pressed.connect(_on_next) + _btn_hbox.add_child(_next_btn) + vbox.add_child(_btn_hbox) + # Hint label + _hint_label = Label.new() + _hint_label.add_theme_font_size_override("font_size", 10) + _hint_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + _hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(_hint_label) + # Initial state: closed book + _cover.visible = true + _spread.visible = true + +func _make_cover() -> Control: + var tex: Texture2D = null + for path in _cover_texture_paths: + var loaded = load(path) as Texture2D + if loaded: + tex = loaded + break + if tex: + var tex_rect = TextureRect.new() + tex_rect.texture = tex + tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + return tex_rect + # Fallback if no cover texture found + var panel = PanelContainer.new() + var style = StyleBoxFlat.new() + style.bg_color = Color(0.28, 0.18, 0.1) + style.border_color = Color(0.15, 0.08, 0.02) + style.set_border_width_all(3) + style.set_corner_radius_all(4) + panel.add_theme_stylebox_override("panel", style) + return panel + +func _make_page_panel() -> Control: + var panel = PanelContainer.new() + # Try to use parchment texture as background + var parchment_tex: Texture2D = load(PAGE_PARCHMENT_PATH) as Texture2D + if parchment_tex: + var style = StyleBoxTexture.new() + style.texture = parchment_tex + style.draw_center = true + panel.add_theme_stylebox_override("panel", style) + else: + # Fallback to flat color + var style = StyleBoxFlat.new() + style.bg_color = Color(0.96, 0.93, 0.84) + style.border_color = Color(0.5, 0.4, 0.3) + style.set_border_width_all(1) + style.set_corner_radius_all(0) + panel.add_theme_stylebox_override("panel", style) + return panel + + +# ══════════════════════════════════════════════════════════════════ +# Spell entries +# ══════════════════════════════════════════════════════════════════ + +func _create_spell_entry(entry_index: int) -> Control: + var panel = PanelContainer.new() + var style = StyleBoxFlat.new() + style.bg_color = Color(0.95, 0.92, 0.82, 0.98) + style.border_color = Color(0.4, 0.35, 0.25) + style.set_border_width_all(1) + style.set_corner_radius_all(4) + panel.add_theme_stylebox_override("panel", style) + var hbox = HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + panel.add_child(hbox) + var icon_rect = TextureRect.new() + icon_rect.custom_minimum_size = Vector2(40, 40) + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + hbox.add_child(icon_rect) + var text_vbox = VBoxContainer.new() + text_vbox.add_theme_constant_override("separation", 2) + var name_label = Label.new() + name_label.add_theme_font_size_override("font_size", 13) + name_label.add_theme_color_override("font_color", Color(0.1, 0.08, 0.04)) + text_vbox.add_child(name_label) + var desc_label = Label.new() + desc_label.add_theme_font_size_override("font_size", 9) + desc_label.add_theme_color_override("font_color", Color(0.2, 0.16, 0.12)) + desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + desc_label.custom_minimum_size = Vector2(120, 0) + text_vbox.add_child(desc_label) + var slot_hbox = HBoxContainer.new() + for slot in ["1", "2", "3"]: + var slot_label = Label.new() + slot_label.name = "Slot" + slot + slot_label.add_theme_font_size_override("font_size", 9) + slot_hbox.add_child(slot_label) + text_vbox.add_child(slot_hbox) + hbox.add_child(text_vbox) + panel.set_meta("entry_index", entry_index) + panel.set_meta("icon", icon_rect) + panel.set_meta("name_label", name_label) + panel.set_meta("desc_label", desc_label) + panel.set_meta("slot_labels", slot_hbox) + panel.gui_input.connect(_on_spell_entry_input.bind(entry_index)) + panel.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + return panel + +func _on_spell_entry_input(event: InputEvent, entry_index: int): + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + selected_spell_index = entry_index + _update_page_content() + accept_event() + + +# ══════════════════════════════════════════════════════════════════ +# Page content +# ══════════════════════════════════════════════════════════════════ + +func _update_page_content(): + var learnt: Array = character_stats.learnt_spells if character_stats else [] + var start = current_page * SPELLS_PER_PAGE + for side in range(2): + var entry = _left_entry if side == 0 else _right_entry + var idx = start + side + var is_selected = (selected_spell_index == side) + if idx < learnt.size(): + var spell_id: String = learnt[idx] + var info = SPELL_INFO.get(spell_id, [spell_id.capitalize(), ""]) + var name_label: Label = entry.get_meta("name_label") + var desc_label: Label = entry.get_meta("desc_label") + var icon_rect: TextureRect = entry.get_meta("icon") + var slot_hbox: HBoxContainer = entry.get_meta("slot_labels") + name_label.text = info[0] + desc_label.text = info[1] + name_label.add_theme_color_override("font_color", Color(0.15, 0.1, 0.05) if is_selected else Color(0.1, 0.08, 0.04)) + var frame_index = SPELL_ICON_FRAMES.get(spell_id, -1) + if frame_index >= 0: + var tex = load(SPELL_ICON_TEXTURE_PATH) as Texture2D + if tex and tex.get_width() > 0 and tex.get_height() > 0: + var atlas = AtlasTexture.new() + atlas.atlas = tex + var cw = float(tex.get_width()) / float(SPELL_ICON_HFRAMES) + var ch = float(tex.get_height()) / float(SPELL_ICON_VFRAMES) + var col = frame_index % SPELL_ICON_HFRAMES + var row = int(frame_index / SPELL_ICON_HFRAMES) + atlas.region = Rect2(col * cw, row * ch, cw, ch) + icon_rect.texture = atlas + var slot_labels = slot_hbox.get_children() + for s in range(3): + var slot = str(s + 1) + var spell_in_slot = character_stats.spell_hotkeys.get(slot, "") if character_stats else "" + var label_node = slot_labels[s] if s < slot_labels.size() else null + if label_node is Label: + label_node.text = " [%s]" % slot + label_node.add_theme_color_override("font_color", Color(0.1, 0.5, 0.15) if spell_in_slot == spell_id else Color(0.3, 0.26, 0.2)) + entry.visible = true + var st = entry.get_theme_stylebox("panel") as StyleBoxFlat + if st: + st.border_color = Color(0.55, 0.4, 0.15) if is_selected else Color(0.4, 0.35, 0.25) + else: + entry.visible = false + +func _hide_spread(): + if _spread: + _spread.visible = false + if _cover: + _cover.visible = true + _cover.set_position(Vector2.ZERO) + _cover.scale = Vector2(1.0, 1.0) + _cover.modulate.a = 1.0 diff --git a/src/scripts/spell_book_ui.gd.uid b/src/scripts/spell_book_ui.gd.uid new file mode 100644 index 0000000..80adf77 --- /dev/null +++ b/src/scripts/spell_book_ui.gd.uid @@ -0,0 +1 @@ +uid://c75u55osgapks