started working on spellbook
BIN
src/assets/gfx/RPG DUNGEON VOL 3_280x280_WALLDIRECTIONS_2x2.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
BIN
src/assets/gfx/ui/page_parchment.png
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
40
src/assets/gfx/ui/page_parchment.png.import
Normal file
@@ -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
|
||||
BIN
src/assets/gfx/ui/page_parchment_small.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
40
src/assets/gfx/ui/page_parchment_small.png.import
Normal file
@@ -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
|
||||
BIN
src/assets/gfx/ui/spellbook_cover.png
Normal file
|
After Width: | Height: | Size: 6.2 MiB |
40
src/assets/gfx/ui/spellbook_cover.png.import
Normal file
@@ -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
|
||||
BIN
src/assets/gfx/ui/spine_leather.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
40
src/assets/gfx/ui/spine_leather.png.import
Normal file
@@ -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
|
||||
46
src/scenes/attack_spell_earth_spike.tscn
Normal file
@@ -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"
|
||||
31
src/scenes/attack_spell_water_bubble.tscn
Normal file
@@ -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
|
||||
32
src/scenes/book_paper.tscn
Normal file
@@ -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")
|
||||
@@ -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]
|
||||
|
||||
106
src/scenes/spellbook.tscn
Normal file
@@ -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"
|
||||
118
src/scripts/attack_spell_earth_spike.gd
Normal file
@@ -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)
|
||||
1
src/scripts/attack_spell_earth_spike.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://jqfj6bsutmdp
|
||||
136
src/scripts/attack_spell_water_bubble.gd
Normal file
@@ -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]
|
||||
1
src/scripts/attack_spell_water_bubble.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bpifj6br77wlk
|
||||
@@ -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:
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ((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()
|
||||
|
||||
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 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()
|
||||
|
||||
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 = ((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)
|
||||
|
||||
823
src/scripts/spell_book_ui.gd
Normal file
@@ -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
|
||||
1
src/scripts/spell_book_ui.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c75u55osgapks
|
||||