started working on spellbook

This commit is contained in:
2026-02-08 16:48:21 +01:00
parent 9e2516a5ab
commit 82219474ec
28 changed files with 2009 additions and 151 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

View 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

View 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"

View 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

View 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")

View File

@@ -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://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://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="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"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_selection"]
bg_color = Color(0, 0, 0, 0) 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] [node name="InventoryContainer" type="Control" parent="." unique_id=-294967296]
visible = false visible = false
clip_contents = true
layout_mode = 3 layout_mode = 3
anchors_preset = 3 anchors_preset = 3
anchor_left = 1.0 anchor_left = 1.0
anchor_top = 1.0 anchor_top = 1.0
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -610.0
offset_top = -450.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="MarginContainer" type="MarginContainer" parent="InventoryContainer" unique_id=935107028] [node name="MarginContainer" type="MarginContainer" parent="InventoryContainer" unique_id=935107028]
layout_mode = 1 layout_mode = 1
anchors_preset = 3 anchors_preset = 15
anchor_left = 1.0 anchor_left = 0.0
anchor_top = 1.0 anchor_top = 0.0
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 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] [node name="Background" type="ColorRect" parent="InventoryContainer/MarginContainer" unique_id=705032704]
layout_mode = 2 layout_mode = 2
@@ -74,8 +72,27 @@ theme_override_constants/margin_bottom = 16
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 4 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] [node name="HBox" type="HBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer" unique_id=1705032704]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 10 theme_override_constants/separation = 10
[node name="StatsPanel" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox" unique_id=-1589934592] [node name="StatsPanel" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox" unique_id=-1589934592]
@@ -162,8 +179,9 @@ text = "0/100
horizontal_alignment = 2 horizontal_alignment = 2
[node name="InventoryPanel" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox" unique_id=1115098112] [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 layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 5 theme_override_constants/separation = 5
[node name="EquipmentLabel" type="Label" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel" unique_id=2115098112] [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" text = "Inventory"
[node name="InventoryScroll" type="ScrollContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel" unique_id=820130816] [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 layout_mode = 2
[node name="InventoryVBox" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel/InventoryScroll" unique_id=1820130816] [node name="InventoryVBox" type="VBoxContainer" parent="InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel/InventoryScroll" unique_id=1820130816]

106
src/scenes/spellbook.tscn Normal file
View 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"

View File

@@ -0,0 +1,118 @@
extends Node2D
# Earth spike spell — same behaviour as frostspike, different frames (37403743) 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)

View File

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

View 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]

View File

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

View File

@@ -71,6 +71,12 @@ var equipment: Dictionary = {
"accessory": null "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 = { @export var baseStats = {
"str": 10, "str": 10,
"dex": 10, "dex": 10,
@@ -550,6 +556,40 @@ func drop_equipment(iItem: Item):
drop_item(iItem) drop_item(iItem)
pass 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): func add_item(iItem: Item):
# Try to stack with existing items if possible # Try to stack with existing items if possible
if iItem.can_have_multiple_of: 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) # Auto-equip if slot is empty (only for equippable items)
# BUT: Do NOT auto-equip BOW weapons (they require arrows in off-hand) # 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: 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: if iItem.equipment_type == Item.EquipmentType.MAINHAND and iItem.weapon_type == Item.WeaponType.BOW:
emit_signal("character_changed", self) emit_signal("character_changed", self)
return return
if iItem.weapon_type == Item.WeaponType.SPELLBOOK:
emit_signal("character_changed", self)
return
var slot_key = "" var slot_key = ""
match iItem.equipment_type: match iItem.equipment_type:

View File

@@ -3266,6 +3266,8 @@ func _init_fog_of_war():
combined_seen = _create_seen_array(dungeon_data.map_size) combined_seen = _create_seen_array(dungeon_data.map_size)
explored_map = _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 # Clear corridor cache when initializing new fog
cached_corridor_mask.clear() cached_corridor_mask.clear()
cached_corridor_rooms.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(): if idx < 0 or idx >= fog_tile_to_room_index.size():
return {} return {}
var ri: int = fog_tile_to_room_index[idx] var ri: int = fog_tile_to_room_index[idx]
if ri < 0: if ri < 0 or ri >= dungeon_data.rooms.size():
return {} return {}
return dungeon_data.rooms[ri] 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) _torch_darken_target_scale = _torch_scale_to_display(raw)
target_scale = _torch_darken_target_scale target_scale = _torch_darken_target_scale
var delta := get_process_delta_time() var delta := get_process_delta_time()
if not (multiplayer.has_multiplayer_peer() and multiplayer.is_server()): # 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)) _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
var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE) var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
cm.color = Color(s, s, s) cm.color = Color(s, s, s)
_sync_ambient_to_canvas_modulate() _sync_ambient_to_canvas_modulate()
@@ -3890,15 +3890,13 @@ func _reapply_torch_darkening() -> void:
raw = _TORCH_DARKEN_DEFAULT_RAW raw = _TORCH_DARKEN_DEFAULT_RAW
var t := _torch_scale_to_display(raw) var t := _torch_scale_to_display(raw)
_torch_darken_target_scale = t _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 := "" var room_id := ""
if in_room: if in_room:
room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h)
_torch_darken_initialized = true _torch_darken_initialized = true
_torch_darken_in_room_last = in_room _torch_darken_in_room_last = in_room
_torch_darken_last_room_id = room_id _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: if abs(t - _last_darkness_debug_scale) > 0.005 or _last_darkness_debug_scale < 0:
_last_darkness_debug_scale = t _last_darkness_debug_scale = t
var reason := "" var reason := ""

View File

@@ -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_potion: AudioStreamPlayer2D = $SfxPotion
@onready var sfx_food: AudioStreamPlayer2D = $SfxFood @onready var sfx_food: AudioStreamPlayer2D = $SfxFood
@onready var sfx_armour: AudioStreamPlayer2D = $SfxArmour @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) # Bar layout constants (align X/Y + bar across rows)
const _BAR_WIDTH: int = 100 const _BAR_WIDTH: int = 100
@@ -114,6 +119,9 @@ var quantity_font: Font = null
# Selection animation # Selection animation
var selection_animation_time: float = 0.0 var selection_animation_time: float = 0.0
# Tab: "inventory" or "spell_book"
var _current_tab: String = "inventory"
func _ready(): func _ready():
# Set layer to be above game but below chat # Set layer to be above game but below chat
layer = 150 layer = 150
@@ -137,6 +145,12 @@ func _ready():
# Setup selection rectangle (already in scene, just configure it) # Setup selection rectangle (already in scene, just configure it)
_setup_selection_rectangle() _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 # Find local player
call_deferred("_find_local_player") call_deferred("_find_local_player")
@@ -1113,7 +1127,16 @@ func _format_item_info(item: Item) -> String:
text += "\n\n" text += "\n\n"
# Controls # 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": if selected_type == "equipment":
text += "Press F to unequip" text += "Press F to unequip"
else: else:
@@ -1445,6 +1468,13 @@ func _input(event):
if not is_open: if not is_open:
return 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) # Arrow key navigation (use ui_left/right/up/down so keybindings work)
var direction = "" var direction = ""
var skip_repeat = event is InputEventKey and event.echo 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"): elif not skip_repeat and event.is_action_pressed("ui_down"):
direction = "down" direction = "down"
if direction != "": 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) _navigate_level_up_stats(direction)
elif selected_type == "equipment": elif selected_type == "equipment":
_navigate_equipment(direction) _navigate_equipment(direction)
@@ -1499,6 +1531,16 @@ func _handle_f_key():
if selected_type == "equipment" and selected_slot != "": if selected_type == "equipment" and selected_slot != "":
var equipped_item = char_stats.equipment[selected_slot] var equipped_item = char_stats.equipment[selected_slot]
if equipped_item: 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) char_stats.unequip_item(equipped_item)
# Play armour sound when unequipping # Play armour sound when unequipping
if sfx_armour: if sfx_armour:
@@ -1540,6 +1582,23 @@ func _handle_f_key():
return return
if selected_type == "item" and selected_item: 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: 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 # Remember which slot the item will be equipped to
var target_slot_name = "" var target_slot_name = ""
@@ -1820,6 +1879,7 @@ func _open_inventory():
_update_ui() _update_ui()
_update_stats() _update_stats()
_apply_tab_visibility()
if not local_player: if not local_player:
_find_local_player() _find_local_player()
@@ -1841,6 +1901,37 @@ func _close_inventory():
_lock_player_controls(false) _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): func _lock_player_controls(lock: bool):
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world: if game_world:

View File

@@ -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) # BOMB item (sprite index 199 = row 9, col 19)
_register_item("bomb", { _register_item("bomb", {
"item_name": "Bomb", "item_name": "Bomb",

View File

@@ -57,7 +57,7 @@ func _warmup_bomb_and_projectiles() -> void:
if n.has_method("setup"): if n.has_method("setup"):
n.setup(Vector2.ZERO, null, Vector2.ZERO, false) n.setup(Vector2.ZERO, null, Vector2.ZERO, false)
n.queue_free() 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 var s = load(path) as PackedScene
if s: if s:
var n = s.instantiate() var n = s.instantiate()

View File

@@ -84,7 +84,8 @@ var is_attacking: bool = false
var is_charging_bow: bool = false # True when holding attack with bow+arrows var is_charging_bow: bool = false # True when holding attack with bow+arrows
var bow_charge_start_time: float = 0.0 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 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_start_time: float = 0.0
var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second) var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second)
var use_spell_charge_particles: bool = false # If true, use red_star particles; if false, use AnimationIncantation var 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_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 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 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 empty_bow_shot_attempts: int = 0
var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync) var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync)
var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference)
@@ -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 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 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 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 healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing
var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile
var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch
@@ -881,11 +887,11 @@ func _setup_player_appearance():
character_stats.add_item(debug_sword) character_stats.add_item(debug_sword)
print("Dwarf player ", name, " spawned with 5 bombs and debug axe/dagger/sword in inventory") 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": if selected_race == "Human":
var starting_tome = ItemDatabase.create_item("tome_of_flames") var starting_tome = ItemDatabase.create_item("tome_of_flames")
if starting_tome: if starting_tome:
character_stats.equipment["offhand"] = starting_tome character_stats.add_item(starting_tome)
var tome_healing = ItemDatabase.create_item("tome_of_healing") var tome_healing = ItemDatabase.create_item("tome_of_healing")
if tome_healing: if tome_healing:
character_stats.add_item(tome_healing) character_stats.add_item(tome_healing)
@@ -2752,6 +2758,7 @@ func _handle_interactions():
# Start disarming - cancel any spell charging # Start disarming - cancel any spell charging
if is_charging_spell: if is_charging_spell:
is_charging_spell = false is_charging_spell = false
spell_charge_hotkey_slot = ""
current_spell_element = "fire" current_spell_element = "fire"
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
@@ -2784,35 +2791,105 @@ func _handle_interactions():
# No nearby trap - reset disarming flag # No nearby trap - reset disarming flag
is_disarming = false is_disarming = false
# Check for spell casting (Tome of Flames, Frostspike, or Healing) # Spell hotkey key state (for 1/2/3 casting from learnt spells)
if character_stats and character_stats.equipment.has("offhand"): var k1 = Input.is_key_pressed(KEY_1)
var offhand_item = character_stats.equipment["offhand"] var k2 = Input.is_key_pressed(KEY_2)
if offhand_item and offhand_item.weapon_type == Item.WeaponType.SPELLBOOK: var k3 = Input.is_key_pressed(KEY_3)
var is_fire = offhand_item.item_name == "Tome of Flames" var key1_just_pressed = k1 and not _key1_was_pressed
var is_frost = offhand_item.item_name == "Tome of Frostspike" var key2_just_pressed = k2 and not _key2_was_pressed
var is_heal = offhand_item.item_name == "Tome of Healing" var key3_just_pressed = k3 and not _key3_was_pressed
if is_fire or is_frost or is_heal: 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 game_world = get_tree().get_first_node_in_group("game_world")
var target_pos = Vector2.ZERO var target_pos = Vector2.ZERO
var heal_target: Node = null 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() target_pos = game_world.get_grid_locked_cursor_position()
elif is_heal: elif is_heal:
heal_target = _get_heal_target() 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 has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) var can_start = is_heal or has_valid_target
var can_start_charge = 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)
# Reuse grabbable from single query above (avoids second get_overlapping_bodies) if can_start and mana_ok and not nearby_grabbable_body and not is_lifting and not held_object:
var nearby_grabbable = nearby_grabbable_body spell_charge_hotkey_slot = slot_pressed
is_charging_spell = true
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: current_spell_element = "healing" if is_heal else ("frost" if spell_id == "frostspike" else ("water" if spell_id == "water_bubble" else ("earth" if spell_id == "earth_spike" else "fire")))
spell_charge_start_time = Time.get_ticks_msec() / 1000.0
spell_incantation_played = false
_start_spell_charge_particles()
_start_spell_charge_incantation()
if has_node("SfxSpellCharge"):
$SfxSpellCharge.play()
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_start.rpc()
elif not mana_ok and is_local_player:
_show_not_enough_mana_text()
if character_stats and (spell_from_offhand or spell_from_hotkey):
var is_fire = false
var is_frost = false
var is_heal = false
var is_water_bubble = false
var is_earth_spike = false
if spell_charge_hotkey_slot != "":
var sid = character_stats.spell_hotkeys.get(spell_charge_hotkey_slot, "")
is_fire = (sid == "flames")
is_frost = (sid == "frostspike")
is_heal = (sid == "healing")
is_water_bubble = (sid == "water_bubble")
is_earth_spike = (sid == "earth_spike")
else:
is_fire = offhand_item.item_name == "Tome of Flames"
is_frost = offhand_item.item_name == "Tome of Frostspike"
is_heal = offhand_item.item_name == "Tome of Healing"
is_water_bubble = offhand_item.item_name == "Tome of Water Bubble"
is_earth_spike = offhand_item.item_name == "Tome of Earth Spike"
if is_fire or is_frost or is_heal or is_water_bubble or is_earth_spike:
var game_world = get_tree().get_first_node_in_group("game_world")
var target_pos = Vector2.ZERO
var heal_target: Node = null
if (is_fire or is_frost or is_earth_spike) and game_world and game_world.has_method("get_grid_locked_cursor_position"):
target_pos = game_world.get_grid_locked_cursor_position()
elif is_heal:
heal_target = _get_heal_target()
var has_valid_target = ((is_fire or is_frost or is_earth_spike) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or is_water_bubble
var can_start_charge = is_heal or has_valid_target
# Reuse grabbable from single query above (avoids second get_overlapping_bodies)
var nearby_grabbable = nearby_grabbable_body
var hotkey_released = (spell_charge_hotkey_slot == "1" and key1_just_released) or (spell_charge_hotkey_slot == "2" and key2_just_released) or (spell_charge_hotkey_slot == "3" and key3_just_released)
if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object:
# Check if player has enough mana before starting to charge # Check if player has enough mana before starting to charge
var has_enough_mana = false var has_enough_mana = false
if character_stats: if character_stats:
if is_fire: if is_fire or is_frost or is_water_bubble or is_earth_spike:
has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost has_enough_mana = character_stats.mp >= 15.0
elif is_frost:
has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost
else: else:
has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost
@@ -2823,7 +2900,7 @@ func _handle_interactions():
return return
is_charging_spell = true is_charging_spell = true
current_spell_element = "healing" if is_heal else ("frost" if is_frost else "fire") current_spell_element = "healing" if is_heal else ("frost" if is_frost else ("water" if is_water_bubble else ("earth" if is_earth_spike else "fire")))
spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_charge_start_time = Time.get_ticks_msec() / 1000.0
spell_incantation_played = false spell_incantation_played = false
_start_spell_charge_particles() _start_spell_charge_particles()
@@ -2834,94 +2911,11 @@ func _handle_interactions():
_sync_spell_charge_start.rpc() _sync_spell_charge_start.rpc()
just_grabbed_this_frame = false just_grabbed_this_frame = false
return return
elif grab_just_released and is_charging_spell: elif (grab_just_released or hotkey_released) and is_charging_spell:
var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
if charge_time < 0.2: 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
is_charging_spell = false is_charging_spell = false
spell_charge_hotkey_slot = ""
current_spell_element = "fire" current_spell_element = "fire"
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
@@ -2934,7 +2928,103 @@ func _handle_interactions():
$SfxSpellIncantation.stop() $SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc() _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) # Check for bomb usage (if bomb equipped in offhand)
# Also check if we're already holding a bomb - if so, skip normal grab handling # 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 is_holding_bomb = true
if character_stats and character_stats.equipment.has("offhand"): if character_stats and character_stats.equipment.has("offhand"):
var offhand_item = character_stats.equipment["offhand"] var offhand_equipped = character_stats.equipment["offhand"]
if offhand_item and offhand_item.weapon_type == Item.WeaponType.BOMB and offhand_item.quantity > 0: 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 # Check if there's a grabbable object nearby - prioritize grabbing over bomb
var nearby_grabbable = null var nearby_grabbable = null
if grab_area: if grab_area:
@@ -2981,9 +3071,9 @@ func _handle_interactions():
else: else:
# Human/Elf: Throw bomb or drop next to player # Human/Elf: Throw bomb or drop next to player
# Consume one bomb # Consume one bomb
offhand_item.quantity -= 1 offhand_equipped.quantity -= 1
var remaining = offhand_item.quantity var remaining = offhand_equipped.quantity
if offhand_item.quantity <= 0: if offhand_equipped.quantity <= 0:
character_stats.equipment["offhand"] = null character_stats.equipment["offhand"] = null
if character_stats: if character_stats:
character_stats.character_changed.emit(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) get_parent().add_child(frost)
print(name, " (synced) spawned frostspike at ", target_position) 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): func _cast_heal_spell(target: Node):
if not target or not is_instance_valid(target): if not target or not is_instance_valid(target):
return return
@@ -4926,9 +5084,9 @@ func _stop_spell_charge_particles():
func _start_spell_charge_incantation(): func _start_spell_charge_incantation():
spell_incantation_fire_ready_shown = false spell_incantation_fire_ready_shown = false
if has_node("AnimationIncantation"): if has_node("AnimationIncantation"):
if _is_healing_spell(): if _is_healing_spell() or current_spell_element == "healing":
$AnimationIncantation.play("healing_charging") $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") $AnimationIncantation.play("frost_charging")
else: else:
$AnimationIncantation.play("fire_charging") $AnimationIncantation.play("fire_charging")
@@ -4937,9 +5095,9 @@ func _update_spell_charge_incantation(charge_progress: float):
if not has_node("AnimationIncantation"): if not has_node("AnimationIncantation"):
return return
if charge_progress >= 1.0 and not spell_incantation_fire_ready_shown: 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") $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") $AnimationIncantation.play("frost_ready")
else: else:
$AnimationIncantation.play("fire_ready") $AnimationIncantation.play("fire_ready")
@@ -4955,10 +5113,14 @@ func _apply_spell_charge_tint():
if not is_charging_spell: if not is_charging_spell:
return return
var tint = spell_charge_tint 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 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 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 = [ var sprites = [
{"sprite": sprite_body, "name": "body"}, {"sprite": sprite_body, "name": "body"},
{"sprite": sprite_boots, "name": "boots"}, {"sprite": sprite_boots, "name": "boots"},
@@ -5196,6 +5358,7 @@ func _sync_spell_charge_start():
func _sync_spell_charge_end(): func _sync_spell_charge_end():
if not is_multiplayer_authority(): if not is_multiplayer_authority():
is_charging_spell = false is_charging_spell = false
spell_charge_hotkey_slot = ""
current_spell_element = "fire" current_spell_element = "fire"
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
@@ -6421,6 +6584,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
if should_cancel: if should_cancel:
is_charging_spell = false is_charging_spell = false
spell_charge_hotkey_slot = ""
spell_incantation_played = false spell_incantation_played = false
_stop_spell_charge_particles() _stop_spell_charge_particles()
_stop_spell_charge_incantation() _stop_spell_charge_incantation()
@@ -7277,15 +7441,13 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
"Human": "Human":
character_stats.setEars(0) character_stats.setEars(0)
# Give Human (Wizard) starting tomes and hat to remote players ONLY when slots are null (initial sync) # Give Human (Wizard) starting tomes and hat to remote players ONLY when headgear empty (initial sync)
# Never overwrite existing equipment - preserves loadout across level transitions
if not is_multiplayer_authority(): if not is_multiplayer_authority():
var offhand_empty = character_stats.equipment["offhand"] == null
var headgear_empty = character_stats.equipment["headgear"] == 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") var starting_tome = ItemDatabase.create_item("tome_of_flames")
if starting_tome: if starting_tome:
character_stats.equipment["offhand"] = starting_tome character_stats.add_item(starting_tome)
var tome_healing = ItemDatabase.create_item("tome_of_healing") var tome_healing = ItemDatabase.create_item("tome_of_healing")
if tome_healing: if tome_healing:
character_stats.add_item(tome_healing) character_stats.add_item(tome_healing)

View 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

View File

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