diff --git a/src/assets/gfx/ui/spellbook_cover_small.png b/src/assets/gfx/ui/spellbook_cover_small.png new file mode 100644 index 0000000..2dd5a4e Binary files /dev/null and b/src/assets/gfx/ui/spellbook_cover_small.png differ diff --git a/src/assets/gfx/ui/spellbook_cover_small.png.import b/src/assets/gfx/ui/spellbook_cover_small.png.import new file mode 100644 index 0000000..b6dc96a --- /dev/null +++ b/src/assets/gfx/ui/spellbook_cover_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpk136b7ld4rm" +path="res://.godot/imported/spellbook_cover_small.png-c8007902877b53ad13e98c79605b99d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/spellbook_cover_small.png" +dest_files=["res://.godot/imported/spellbook_cover_small.png-c8007902877b53ad13e98c79605b99d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/ui/spine_inner_binding.png b/src/assets/gfx/ui/spine_inner_binding.png new file mode 100644 index 0000000..977e43c Binary files /dev/null and b/src/assets/gfx/ui/spine_inner_binding.png differ diff --git a/src/assets/gfx/ui/spine_inner_binding.png.import b/src/assets/gfx/ui/spine_inner_binding.png.import new file mode 100644 index 0000000..e0a96b0 --- /dev/null +++ b/src/assets/gfx/ui/spine_inner_binding.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckrthw12d1gwb" +path="res://.godot/imported/spine_inner_binding.png-fa5d560b7cfc1892b8181ab9e269f289.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/spine_inner_binding.png" +dest_files=["res://.godot/imported/spine_inner_binding.png-fa5d560b7cfc1892b8181ab9e269f289.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/ui/spine_inner_binding_small.png b/src/assets/gfx/ui/spine_inner_binding_small.png new file mode 100644 index 0000000..cb161f1 Binary files /dev/null and b/src/assets/gfx/ui/spine_inner_binding_small.png differ diff --git a/src/assets/gfx/ui/spine_inner_binding_small.png.import b/src/assets/gfx/ui/spine_inner_binding_small.png.import new file mode 100644 index 0000000..c951ffd --- /dev/null +++ b/src/assets/gfx/ui/spine_inner_binding_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wcpely4edb4a" +path="res://.godot/imported/spine_inner_binding_small.png-1afe3288b0162c03127f1d9584faddba.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/spine_inner_binding_small.png" +dest_files=["res://.godot/imported/spine_inner_binding_small.png-1afe3288b0162c03127f1d9584faddba.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/ui/spine_leather_small.png b/src/assets/gfx/ui/spine_leather_small.png new file mode 100644 index 0000000..49a50ab Binary files /dev/null and b/src/assets/gfx/ui/spine_leather_small.png differ diff --git a/src/assets/gfx/ui/spine_leather_small.png.import b/src/assets/gfx/ui/spine_leather_small.png.import new file mode 100644 index 0000000..68e25b2 --- /dev/null +++ b/src/assets/gfx/ui/spine_leather_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgq3fup6clg8y" +path="res://.godot/imported/spine_leather_small.png-188cfce5b6aa1942b496990b84d5db37.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/spine_leather_small.png" +dest_files=["res://.godot/imported/spine_leather_small.png-188cfce5b6aa1942b496990b84d5db37.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/default_bus_layout.tres b/src/default_bus_layout.tres index 80f25de..9052fd8 100644 --- a/src/default_bus_layout.tres +++ b/src/default_bus_layout.tres @@ -5,11 +5,6 @@ resource_name = "Reverb" room_size = 0.51 wet = 0.28 -[sub_resource type="AudioEffectLowPassFilter" id="AudioEffectLowPassFilter_j3pel"] -resource_name = "LowPassFilter" -cutoff_hz = 958.0 -resonance = 0.75 - [resource] bus/1/name = &"Sfx" bus/1/solo = false @@ -25,5 +20,3 @@ bus/2/mute = false bus/2/bypass_fx = false bus/2/volume_db = 0.0 bus/2/send = &"Sfx" -bus/2/effect/0/effect = SubResource("AudioEffectLowPassFilter_j3pel") -bus/2/effect/0/enabled = true diff --git a/src/scenes/book_paper.tscn b/src/scenes/book_paper.tscn index f81763f..ee78826 100644 --- a/src/scenes/book_paper.tscn +++ b/src/scenes/book_paper.tscn @@ -1,9 +1,11 @@ [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"] +[ext_resource type="Script" uid="uid://b53v027falxj8" path="res://scripts/book_paper.gd" id="2_script"] -[sub_resource type="Animation" id="Animation_ttuta"] -resource_name = "turn_page" +[sub_resource type="Animation" id="Animation_reset"] +resource_name = "RESET" +length = 0.01 tracks/0/type = "value" tracks/0/imported = false tracks/0/enabled = true @@ -11,22 +13,44 @@ tracks/0/path = NodePath("PagePolygon:polygon") tracks/0/interp = 1 tracks/0/loop_wrap = true tracks/0/keys = { -"times": PackedFloat32Array(), -"transitions": PackedFloat32Array(), +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), "update": 0, -"values": [] +"values": [PackedVector2Array(138, 0, 182, 0, 182, 256, 138, 256, 92, 256, 42, 256, 0, 256, 0, 0, 42, 0, 92, 0)] +} + +[sub_resource type="Animation" id="Animation_ttuta"] +resource_name = "turn_page" +length = 0.6 +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(0, 0.075, 0.15, 0.225, 0.3, 0.4, 0.5, 0.6), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [PackedVector2Array(138, 0, 182, 0, 182, 256, 138, 256, 92, 256, 42, 256, 0, 256, 0, 0, 42, 0, 92, 0), PackedVector2Array(117, 0, 155, 0, 155, 264, 117, 276, 78, 282, 36, 266, 0, 256, 0, 0, 36, 0, 78, 0), PackedVector2Array(76, 0, 100, 0, 100, 278, 76, 311, 51, 328, 23, 284, 0, 256, 0, 0, 23, 0, 51, 0), PackedVector2Array(28, 0, 36, 0, 36, 292, 28, 346, 18, 373, 8, 301, 0, 256, 0, 0, 8, 0, 18, 0), PackedVector2Array(-28, 0, -36, 0, -36, 292, -28, 346, -18, 373, -8, 301, 0, 256, 0, 0, -8, 0, -18, 0), PackedVector2Array(-83, 0, -109, 0, -109, 280, -83, 316, -55, 334, -25, 286, 0, 256, 0, 0, -25, 0, -55, 0), PackedVector2Array(-124, 0, -164, 0, -164, 264, -124, 276, -83, 282, -38, 266, 0, 256, 0, 0, -38, 0, -83, 0), PackedVector2Array(-138, 0, -182, 0, -182, 256, -138, 256, -92, 256, -42, 256, 0, 256, 0, 0, -42, 0, -92, 0)] } [sub_resource type="AnimationLibrary" id="AnimationLibrary_i033y"] _data = { +&"RESET": SubResource("Animation_reset"), &"turn_page": SubResource("Animation_ttuta") } [node name="BookPaper" type="Node2D" unique_id=723859984] +script = ExtResource("2_script") [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) +uv = PackedVector2Array(138, 0, 182, 0, 182, 256, 138, 256, 92, 256, 42, 256, 0, 256, 0, 0, 42, 0, 92, 0) +polygons = [PackedInt32Array(7, 8, 5, 6), PackedInt32Array(8, 9, 4, 5), PackedInt32Array(9, 0, 3, 4), PackedInt32Array(0, 1, 2, 3)] [node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=2024937129] libraries/ = SubResource("AnimationLibrary_i033y") + +[node name="Camera2D" type="Camera2D" parent="." unique_id=225599989] diff --git a/src/scenes/paper.tscn b/src/scenes/paper.tscn new file mode 100644 index 0000000..5414ffd --- /dev/null +++ b/src/scenes/paper.tscn @@ -0,0 +1,60 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Shader" path="res://shaders/page_turn.gdshader" id="2_shader"] +[ext_resource type="Script" path="res://scripts/paper.gd" id="3_script"] + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_paper"] +shader = ExtResource("2_shader") +shader_parameter/progress = 0.0 +shader_parameter/page_width = 182.0 +shader_parameter/page_height = 256.0 +shader_parameter/arch_amount = 80.0 + +[sub_resource type="Animation" id="Animation_reset"] +resource_name = "RESET" +length = 0.01 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PagePoly:material:shader_parameter/progress") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [0.0] +} + +[sub_resource type="Animation" id="Animation_turn"] +resource_name = "turn_page" +length = 2.0 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PagePoly:material:shader_parameter/progress") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 2.0), +"transitions": PackedFloat32Array(1, 1), +"update": 0, +"values": [0.0, 1.0] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_paper"] +_data = { +&"RESET": SubResource("Animation_reset"), +&"turn_page": SubResource("Animation_turn") +} + +[node name="Paper" type="Node2D"] +script = ExtResource("3_script") + +[node name="PagePoly" type="Polygon2D" parent="."] +material = SubResource("ShaderMaterial_paper") + +[node name="AnimationPlayer" type="AnimationPlayer" parent="."] +libraries/ = SubResource("AnimationLibrary_paper") + +[node name="Camera2D" type="Camera2D" parent="."] diff --git a/src/scenes/test_spellbook.tscn b/src/scenes/test_spellbook.tscn new file mode 100644 index 0000000..8c67bbe --- /dev/null +++ b/src/scenes/test_spellbook.tscn @@ -0,0 +1,31 @@ +[gd_scene format=3 uid="uid://dce5khb0f44px"] + +[ext_resource type="Script" uid="uid://c75u55osgapks" path="res://scripts/spell_book_ui.gd" id="1_sb"] +[ext_resource type="Script" uid="uid://cyqdk71cnqipt" path="res://scripts/test_spellbook.gd" id="2_test"] + +[node name="TestSpellbook" type="Control" unique_id=1924210192] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("2_test") + +[node name="ColorRect" type="ColorRect" parent="." unique_id=1627853997] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.12, 0.1, 0.08, 1) + +[node name="SpellBookPanel" type="Control" parent="." unique_id=1024751319] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_sb") diff --git a/src/scripts/book_paper.gd b/src/scripts/book_paper.gd new file mode 100644 index 0000000..59df9d7 --- /dev/null +++ b/src/scripts/book_paper.gd @@ -0,0 +1,29 @@ +extends Node2D +## Polygon2D page-turn driven by AnimationPlayer. +## The "turn_page" animation directly keyframes PagePolygon:polygon vertices. +## Points 7,6 (spine edge at x=0) never move. All other column pairs fold right-to-left. +## +## Column pairs (top, bottom): +## Spine (FIXED): pts 7, 6 (x=0) +## Col 1: pts 8, 5 (x=42) +## Col 2: pts 9, 4 (x=92) +## Col 3: pts 0, 3 (x=138) +## Col 4: pts 1, 2 (x=182, leading edge) + +@onready var _anim: AnimationPlayer = $AnimationPlayer + + +## Play the page turn animation (right-to-left fold). +func play_turn() -> void: + if _anim: + _anim.play("turn_page") + +## Play the page turn in reverse (left-to-right unfold). +func play_turn_reverse() -> void: + if _anim: + _anim.play_backwards("turn_page") + +## Reset to flat (unturned) state immediately. +func reset_flat() -> void: + if _anim: + _anim.play("RESET") diff --git a/src/scripts/book_paper.gd.uid b/src/scripts/book_paper.gd.uid new file mode 100644 index 0000000..661b07b --- /dev/null +++ b/src/scripts/book_paper.gd.uid @@ -0,0 +1 @@ +uid://b53v027falxj8 diff --git a/src/scripts/paper.gd b/src/scripts/paper.gd new file mode 100644 index 0000000..8e005ec --- /dev/null +++ b/src/scripts/paper.gd @@ -0,0 +1,192 @@ +extends Node2D +## Polygon2D page turn with vertex-shader deformation and front/back side support. +## Two SubViewports render the front and back page content. The shader switches +## between them at the midpoint of the turn. Add your content (spell icons, +## descriptions, etc.) to the returned Control nodes from get_front_content() +## and get_back_content(). +## +## Test controls (standalone): +## SPACE / ENTER = play turn +## BACKSPACE = play reverse +## R = reset flat + +const GRID_COLS: int = 10 +const GRID_ROWS: int = 8 + +@onready var _poly: Polygon2D = $PagePoly +@onready var _anim: AnimationPlayer = $AnimationPlayer + +var _front_viewport: SubViewport +var _back_viewport: SubViewport +var _front_content: Control +var _back_content: Control +var _page_size: Vector2 + + +func _ready() -> void: + # Get page size from the parchment texture. + var parchment: Texture2D = preload("res://assets/gfx/ui/page_parchment_small.png") + _page_size = parchment.get_size() + + _setup_viewports(parchment) + _build_grid() + _setup_test_content() + print("Paper scene ready. Press SPACE to turn, BACKSPACE to reverse, R to reset.") + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + match event.keycode: + KEY_SPACE, KEY_ENTER: + play_turn() + KEY_BACKSPACE: + play_turn_reverse() + KEY_R: + reset_flat() + + +## Play the page turn animation (right-to-left). +func play_turn() -> void: + if _anim: + _anim.play("turn_page") + + +## Play the page turn in reverse (left-to-right). +func play_turn_reverse() -> void: + if _anim: + _anim.play_backwards("turn_page") + + +## Reset to flat (unturned) state immediately. +func reset_flat() -> void: + if _anim: + _anim.play("RESET") + + +## Returns the Control node for the FRONT side. Add children here for front content. +func get_front_content() -> Control: + return _front_content + + +## Returns the Control node for the BACK side. Add children here for back content. +func get_back_content() -> Control: + return _back_content + + +## Create SubViewports for front and back, each with a parchment background. +func _setup_viewports(parchment: Texture2D) -> void: + var w: int = int(_page_size.x) + var h: int = int(_page_size.y) + + # --- Front viewport --- + _front_viewport = SubViewport.new() + _front_viewport.name = "FrontViewport" + _front_viewport.size = Vector2i(w, h) + _front_viewport.transparent_bg = false + _front_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(_front_viewport) + + var front_bg := TextureRect.new() + front_bg.texture = parchment + front_bg.stretch_mode = TextureRect.STRETCH_SCALE + front_bg.set_anchors_preset(Control.PRESET_FULL_RECT) + _front_viewport.add_child(front_bg) + + _front_content = Control.new() + _front_content.name = "FrontContent" + _front_content.set_anchors_preset(Control.PRESET_FULL_RECT) + _front_viewport.add_child(_front_content) + + # --- Back viewport --- + _back_viewport = SubViewport.new() + _back_viewport.name = "BackViewport" + _back_viewport.size = Vector2i(w, h) + _back_viewport.transparent_bg = false + _back_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(_back_viewport) + + var back_bg := TextureRect.new() + back_bg.texture = parchment + back_bg.stretch_mode = TextureRect.STRETCH_SCALE + back_bg.set_anchors_preset(Control.PRESET_FULL_RECT) + _back_viewport.add_child(back_bg) + + _back_content = Control.new() + _back_content.name = "BackContent" + _back_content.set_anchors_preset(Control.PRESET_FULL_RECT) + _back_viewport.add_child(_back_content) + + # --- Assign textures to the polygon --- + _poly.texture = _front_viewport.get_texture() + + var mat: ShaderMaterial = _poly.material as ShaderMaterial + if mat: + mat.set_shader_parameter("back_texture", _back_viewport.get_texture()) + + +## Build a dense polygon grid for smooth vertex-shader deformation. +func _build_grid() -> void: + var page_w: float = _page_size.x + var page_h: float = _page_size.y + + # Tell the shader the page dimensions. + var mat: ShaderMaterial = _poly.material as ShaderMaterial + if mat: + mat.set_shader_parameter("page_width", page_w) + mat.set_shader_parameter("page_height", page_h) + + var cols: int = GRID_COLS + var rows: int = GRID_ROWS + var num_verts_x: int = cols + 1 + var num_verts_y: int = rows + 1 + + var verts := PackedVector2Array() + var uvs := PackedVector2Array() + verts.resize(num_verts_x * num_verts_y) + uvs.resize(num_verts_x * num_verts_y) + + for row_i in range(num_verts_y): + for col_i in range(num_verts_x): + var x: float = (float(col_i) / float(cols)) * page_w + var y: float = (float(row_i) / float(rows)) * page_h + var idx: int = row_i * num_verts_x + col_i + verts[idx] = Vector2(x, y) + uvs[idx] = Vector2(x, y) + + _poly.polygon = verts + _poly.uv = uvs + + var polys: Array[PackedInt32Array] = [] + for row_i in range(rows): + for col_i in range(cols): + var tl: int = row_i * num_verts_x + col_i + var tr: int = tl + 1 + var bl: int = (row_i + 1) * num_verts_x + col_i + var br: int = bl + 1 + polys.append(PackedInt32Array([tl, tr, br, bl])) + + _poly.polygons = polys + + # Center on screen for standalone testing. + _poly.position = Vector2(-page_w / 2.0, -page_h / 2.0) + + +## Add test labels so front/back are clearly visible when testing. +func _setup_test_content() -> void: + var front_label := Label.new() + front_label.text = "FRONT SIDE" + front_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + front_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + front_label.set_anchors_preset(Control.PRESET_FULL_RECT) + front_label.add_theme_font_size_override("font_size", 20) + front_label.add_theme_color_override("font_color", Color(0.3, 0.15, 0.05)) + _front_content.add_child(front_label) + + var back_label := Label.new() + back_label.text = "BACK SIDE" + back_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + back_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + back_label.set_anchors_preset(Control.PRESET_FULL_RECT) + back_label.add_theme_font_size_override("font_size", 20) + back_label.add_theme_color_override("font_color", Color(0.5, 0.1, 0.1)) + _back_content.add_child(back_label) diff --git a/src/scripts/paper.gd.uid b/src/scripts/paper.gd.uid new file mode 100644 index 0000000..b01e535 --- /dev/null +++ b/src/scripts/paper.gd.uid @@ -0,0 +1 @@ +uid://y8cle55rlpg2 diff --git a/src/scripts/spell_book_ui.gd b/src/scripts/spell_book_ui.gd index 5956bd9..0fc374c 100644 --- a/src/scripts/spell_book_ui.gd +++ b/src/scripts/spell_book_ui.gd @@ -1,45 +1,56 @@ 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. +## Spell Book UI — rebuilt around the vertex-shader page-turn animation. +## +## Structure: +## SpellBookUI (Control) +## └ VBoxContainer +## ├ BookWrapper (Control, clips) +## │ └ BookRoot (Control, scaled) +## │ ├ SpineRect (TextureRect) +## │ ├ LeftPageBG (static parchment left page) +## │ ├ PageStack (Node2D — holds Paper polygons) +## │ │ ├ Paper_0 (Polygon2D + shader + SubViewports) +## │ │ ├ Paper_1 ... +## │ │ └ Paper_N ... +## │ └ FrontCover (Polygon2D + shader, arch_amount=0) +## ├ NavButtons (HBoxContainer) +## └ HintLabel +## +## Each Paper polygon uses page_turn.gdshader for the arch-based vertex +## deformation. Front & back SubViewports render spell content so it +## deforms together with the page. +## +## The cover uses the same shader but with arch_amount = 0 for a stiff +## rotation around the spine. -# ── 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 +# ── Layout ──────────────────────────────────────────────────────── +const SPINE_WIDTH: int = 2 +const PAGE_WIDTH: int = 182 +const PAGE_HEIGHT: int = 256 # page content area (parchment) +const BOOK_HEIGHT: int = 272 # overall book/cover/spine height +const PAGE_Y_OFFSET: int = int((BOOK_HEIGHT - PAGE_HEIGHT) / 2.0) # 8 px top/bottom # ── 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 COVER_OPEN_DURATION: float = 0.7 +const COVER_PAUSE: float = 0.15 +const CASCADE_FLIPS: int = 6 +const PLACEHOLDER_PAGES: int = CASCADE_FLIPS * 2 # page slots before real spells (even, so first spell lands on left page) +const CASCADE_MIN_DUR: float = 0.06 +const CASCADE_MAX_DUR: float = 0.14 +const PAGE_TURN_DURATION: float = 0.6 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 +# ── Polygon grid density ───────────────────────────────────────── +const GRID_COLS: int = 10 +const GRID_ROWS: int = 8 -# ── 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) ───── +# ── Spell data ──────────────────────────────────────────────────── 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 + "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."], @@ -49,46 +60,53 @@ const SPELL_INFO: Dictionary = { "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" +# ── Texture paths ───────────────────────────────────────────────── +const COVER_TEXTURE_PATH: String = "res://assets/gfx/ui/spellbook_cover_small.png" +const PAGE_PARCHMENT_PATH: String = "res://assets/gfx/ui/page_parchment_small.png" +const SPINE_LEATHER_PATH: String = "res://assets/gfx/ui/spine_inner_binding_small.png" -# State +# ── State ───────────────────────────────────────────────────────── var character_stats: CharacterStats = null -var current_page: int = 0 -var selected_spell_index: int = -1 +var current_spread: int = 0 # which 2-spell spread is visible +var selected_spell_index: int = -1 # 0 = left spell, 1 = right spell +var _total_spreads: int = 1 +var _is_animating: bool = false +var _play_cover_open: bool = true -# UI nodes +# ── Node references ─────────────────────────────────────────────── 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 _spine_rect: TextureRect = null +var _left_page_bg: Control = null # static parchment left panel +var _left_page_content: Control = null # content drawn onto left panel +var _right_page_bg: Control = null # static parchment right panel (shows when no paper on top) +var _right_page_content: Control = null # content drawn onto right panel +var _page_stack: Node2D = null +var _cover_poly: Polygon2D = null +var _cover_mat: ShaderMaterial = 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 +var _left_mask: ColorRect = null # hides left side until cover-open reveal -# 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 +# ── Paper pages ─────────────────────────────────────────────────── +# Each entry: { poly, material, front_vp, back_vp, front_content, back_content, turned } +var _papers: Array = [] + +# ── Spell entry references ──────────────────────────────────────── +# Indexed by absolute spell position. Each: { icon, name_label, desc_label, slot_labels, root } +var _spell_entries: Dictionary = {} + +# ── Tweens ──────────────────────────────────────────────────────── +var _cover_tween: Tween = null +var _page_tween: Tween = null +var _cascade_index: int = 0 + +# ── Preloads ────────────────────────────────────────────────────── +var _page_turn_shader: Shader = null +var _parchment_tex: Texture2D = null +var _cover_tex: Texture2D = null # ══════════════════════════════════════════════════════════════════ @@ -96,39 +114,28 @@ var _opening_flip_tween: Tween = null # ══════════════════════════════════════════════════════════════════ func _ready(): + _page_turn_shader = load("res://shaders/page_turn.gdshader") as Shader + _parchment_tex = load(PAGE_PARCHMENT_PATH) as Texture2D + _cover_tex = load(COVER_TEXTURE_PATH) as Texture2D _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 not is_node_ready(): + return 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 + _reset_to_closed() + func _input(event): if not visible or not character_stats: @@ -146,366 +153,899 @@ func _input(event): # ══════════════════════════════════════════════════════════════════ -# Public API +# Public API (called by inventory_ui.gd) # ══════════════════════════════════════════════════════════════════ 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() + # If the cover-open animation is already running, don't restart everything. + if _is_animating and not _play_cover_open: + return + _kill_all_tweens() + _is_animating = false + _rebuild_pages() + _update_all_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: + if visible and _play_cover_open: _start_cover_open() # ══════════════════════════════════════════════════════════════════ -# Bezier helpers +# UI construction # ══════════════════════════════════════════════════════════════════ -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 _build_book_ui(): + # ── Outer VBox ── + var vbox = VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + vbox.add_theme_constant_override("separation", 6) + add_child(vbox) -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 + # ── Book wrapper (clips, 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) -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 + # ── Book root (scaled to fit) ── + # Layout: [Left Page] [Spine] [Right Page] + var total_width: int = PAGE_WIDTH + SPINE_WIDTH + PAGE_WIDTH + _book_root = Panel.new() + var root_style := StyleBoxFlat.new() + root_style.bg_color = Color(0.12, 0.08, 0.05, 1.0) # dark brown behind binding + _book_root.add_theme_stylebox_override("panel", root_style) + _book_root.set_size(Vector2(total_width, BOOK_HEIGHT)) + _book_root.set_position(Vector2.ZERO) + # Clip ALL children (including Polygon2D pages) to the book area. + # This prevents arched pages from rendering above/below the book. + _book_root.clip_children = CanvasItem.CLIP_CHILDREN_AND_DRAW + _book_wrapper.add_child(_book_root) -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 + # ── Inner binding (full width, drawn BEHIND everything) ── + _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 + var binding_h: int = 258 + var binding_y: int = int((BOOK_HEIGHT - binding_h) / 2.0) + _spine_rect.set_position(Vector2(0, binding_y)) + _spine_rect.set_size(Vector2(total_width, binding_h)) + _book_root.add_child(_spine_rect) -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 + # ── Static left page background ── + _left_page_bg = _make_page_panel() + _left_page_bg.set_position(Vector2(0, PAGE_Y_OFFSET)) + _left_page_bg.set_size(Vector2(PAGE_WIDTH, PAGE_HEIGHT)) + _left_page_bg.custom_minimum_size = Vector2(PAGE_WIDTH, PAGE_HEIGHT) + _book_root.add_child(_left_page_bg) + # Content overlay for the static left page + _left_page_content = Control.new() + _left_page_content.set_position(Vector2.ZERO) + _left_page_content.set_size(Vector2(PAGE_WIDTH, PAGE_HEIGHT)) + _left_page_content.set_anchors_preset(Control.PRESET_FULL_RECT) + _left_page_bg.add_child(_left_page_content) + + # ── Static right page background (visible when no paper covers it) ── + _right_page_bg = _make_page_panel() + _right_page_bg.set_position(Vector2(PAGE_WIDTH + SPINE_WIDTH, PAGE_Y_OFFSET)) + _right_page_bg.set_size(Vector2(PAGE_WIDTH, PAGE_HEIGHT)) + _right_page_bg.custom_minimum_size = Vector2(PAGE_WIDTH, PAGE_HEIGHT) + _book_root.add_child(_right_page_bg) + # Content overlay for the static right page + _right_page_content = Control.new() + _right_page_content.set_position(Vector2.ZERO) + _right_page_content.set_size(Vector2(PAGE_WIDTH, PAGE_HEIGHT)) + _right_page_content.set_anchors_preset(Control.PRESET_FULL_RECT) + _right_page_bg.add_child(_right_page_content) + + # ── Page stack (Node2D holds all paper polygons) ── + _page_stack = Node2D.new() + _page_stack.name = "PageStack" + # Position so polygon x=0 aligns with the right-page area. + # When turned (progress=1), the page flips to negative x, landing + # over the left page area — just like a real page draping over the binding. + _page_stack.position = Vector2(PAGE_WIDTH + SPINE_WIDTH, PAGE_Y_OFFSET) + _book_root.add_child(_page_stack) + + # ── Left mask (hides left page + binding until the cover-open reveal) ── + _left_mask = ColorRect.new() + _left_mask.color = Color(0.12, 0.08, 0.05, 1.0) # matches Panel bg + _left_mask.set_position(Vector2(0, 0)) + _left_mask.set_size(Vector2(PAGE_WIDTH + SPINE_WIDTH, BOOK_HEIGHT)) + _left_mask.z_index = 199 # above pages/binding, below cover + _book_root.add_child(_left_mask) + + # ── Front cover (Polygon2D with stiff shader) ── + _build_cover() + + # ── 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) + + # Start in closed state + _left_page_bg.visible = true # ══════════════════════════════════════════════════════════════════ -# Cover opening (horizontal squeeze animation) +# Cover +# ══════════════════════════════════════════════════════════════════ + +func _build_cover(): + ## One page wide, sits on the right side, hinges at the spine to open left. + var cover_w: float = float(PAGE_WIDTH) + var cover_h: float = float(BOOK_HEIGHT) + + _cover_mat = ShaderMaterial.new() + _cover_mat.shader = _page_turn_shader + _cover_mat.set_shader_parameter("progress", 0.0) + _cover_mat.set_shader_parameter("page_width", cover_w) + _cover_mat.set_shader_parameter("page_height", cover_h) + _cover_mat.set_shader_parameter("arch_amount", 0.0) + + _cover_poly = Polygon2D.new() + _cover_poly.name = "FrontCover" + _cover_poly.material = _cover_mat + _cover_poly.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + + if _cover_tex: + _cover_poly.texture = _cover_tex + _cover_poly.z_index = 200 + + _build_polygon_grid(_cover_poly, cover_w, cover_h) + + # Position at right-page area; shader rotates around x=0 (the spine edge). + _cover_poly.position = Vector2(PAGE_WIDTH + SPINE_WIDTH, 0) + _book_root.add_child(_cover_poly) + + +func _build_polygon_grid(poly: Polygon2D, w: float, h: float) -> void: + ## Build a dense vertex grid for smooth shader deformation. + var cols: int = GRID_COLS + var rows: int = GRID_ROWS + var verts_x: int = cols + 1 + var verts_y: int = rows + 1 + + var verts := PackedVector2Array() + var uvs := PackedVector2Array() + verts.resize(verts_x * verts_y) + uvs.resize(verts_x * verts_y) + + for ry in range(verts_y): + for cx in range(verts_x): + var x: float = (float(cx) / float(cols)) * w + var y: float = (float(ry) / float(rows)) * h + var idx: int = ry * verts_x + cx + verts[idx] = Vector2(x, y) + uvs[idx] = Vector2(x, y) + + poly.polygon = verts + poly.uv = uvs + + var polys: Array[PackedInt32Array] = [] + for ry in range(rows): + for cx in range(cols): + var tl: int = ry * verts_x + cx + var top_r: int = tl + 1 + var bl: int = (ry + 1) * verts_x + cx + var br: int = bl + 1 + polys.append(PackedInt32Array([tl, top_r, br, bl])) + + poly.polygons = polys + + +func _make_page_panel() -> PanelContainer: + ## Create a PanelContainer with parchment texture background. + var panel := PanelContainer.new() + if _parchment_tex: + var style := StyleBoxTexture.new() + style.texture = _parchment_tex + style.draw_center = true + panel.add_theme_stylebox_override("panel", style) + else: + 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) + panel.add_theme_stylebox_override("panel", style) + return panel + + +# ══════════════════════════════════════════════════════════════════ +# Paper pages (Polygon2D + SubViewports) +# ══════════════════════════════════════════════════════════════════ + +func _rebuild_pages(): + ## Rebuild all paper pages to match current spell list. + # Clear existing papers + for p in _papers: + if p.poly and is_instance_valid(p.poly): + p.poly.queue_free() + if p.front_vp and is_instance_valid(p.front_vp): + p.front_vp.queue_free() + if p.back_vp and is_instance_valid(p.back_vp): + p.back_vp.queue_free() + _papers.clear() + _spell_entries.clear() + + var learnt: Array = character_stats.learnt_spells if character_stats else [] + # Page slot layout: slot 0 = static left, then Paper_i front = 2*i+1, back = 2*i+2. + # The first PLACEHOLDER_PAGES slots are filler; real spells start after that. + var total_slots_needed: int = PLACEHOLDER_PAGES + learnt.size() + var num_papers: int = maxi(0, ceili((total_slots_needed - 1) / 2.0)) if total_slots_needed > 1 else 0 + # Guarantee enough papers for the cascade + at least one page after landing + @warning_ignore("INTEGER_DIVISION") + var min_papers: int = (PLACEHOLDER_PAGES / 2) + 1 # cascade papers + right page at landing + num_papers = maxi(num_papers, min_papers) + _total_spreads = maxi(1, num_papers + 1) # spread 0 = initial view + current_spread = clampi(current_spread, 0, _total_spreads - 1) + selected_spell_index = -1 + + for i in range(num_papers): + _create_paper(i, num_papers) + + +func _create_paper(index: int, _total: int): + ## Create one paper page polygon with front/back SubViewports. + var pw: float = float(PAGE_WIDTH) + var ph: float = float(PAGE_HEIGHT) + + # ── Shader material ── + var mat := ShaderMaterial.new() + mat.shader = _page_turn_shader + mat.set_shader_parameter("progress", 0.0) + mat.set_shader_parameter("page_width", pw) + mat.set_shader_parameter("page_height", ph) + mat.set_shader_parameter("arch_amount", 80.0) + + # ── Polygon2D ── + var poly := Polygon2D.new() + poly.name = "Paper_%d" % index + poly.material = mat + _build_polygon_grid(poly, pw, ph) + poly.position = Vector2.ZERO # relative to PageStack which is already at the right-page x + poly.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + + # z-ordering: Paper_0 on top (highest z), Paper_N at bottom + poly.z_index = 100 - index + + _page_stack.add_child(poly) + + # ── Front SubViewport ── + var front_vp := SubViewport.new() + front_vp.name = "FrontVP_%d" % index + front_vp.size = Vector2i(int(pw), int(ph)) + front_vp.transparent_bg = false + front_vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(front_vp) + + var front_bg := TextureRect.new() + if _parchment_tex: + front_bg.texture = _parchment_tex + front_bg.stretch_mode = TextureRect.STRETCH_SCALE + front_bg.set_anchors_preset(Control.PRESET_FULL_RECT) + front_vp.add_child(front_bg) + + var front_content := Control.new() + front_content.name = "FrontContent" + front_content.set_anchors_preset(Control.PRESET_FULL_RECT) + front_vp.add_child(front_content) + + # ── Back SubViewport ── + var back_vp := SubViewport.new() + back_vp.name = "BackVP_%d" % index + back_vp.size = Vector2i(int(pw), int(ph)) + back_vp.transparent_bg = false + back_vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(back_vp) + + var back_bg := TextureRect.new() + if _parchment_tex: + back_bg.texture = _parchment_tex + back_bg.stretch_mode = TextureRect.STRETCH_SCALE + back_bg.set_anchors_preset(Control.PRESET_FULL_RECT) + back_vp.add_child(back_bg) + + var back_content := Control.new() + back_content.name = "BackContent" + back_content.set_anchors_preset(Control.PRESET_FULL_RECT) + back_vp.add_child(back_content) + + # ── Assign textures ── + poly.texture = front_vp.get_texture() + mat.set_shader_parameter("back_texture", back_vp.get_texture()) + + # ── Store reference ── + _papers.append({ + "poly": poly, + "material": mat, + "front_vp": front_vp, + "back_vp": back_vp, + "front_content": front_content, + "back_content": back_content, + "turned": false, + }) + + +# ══════════════════════════════════════════════════════════════════ +# Spell content rendering +# ══════════════════════════════════════════════════════════════════ + +func _update_all_content(): + ## Populate every page side with the appropriate spell data. + ## The first PLACEHOLDER_PAGES page-slots are filler (lorem ipsum). + ## Real spells start at slot PLACEHOLDER_PAGES onward. + var learnt: Array = character_stats.learnt_spells if character_stats else [] + _spell_entries.clear() + + # ── Static left & right pages (based on current_spread) ── + _update_static_pages() + + # ── Paper pages ── + for i in range(_papers.size()): + var paper = _papers[i] + + # Front side → slot 2*i + 1 + _clear_children(paper.front_content) + var front_slot: int = 2 * i + 1 + var front_spell: int = front_slot - PLACEHOLDER_PAGES + if front_spell >= 0 and front_spell < learnt.size(): + _populate_spell_side(paper.front_content, learnt[front_spell], front_slot) + else: + _populate_placeholder(paper.front_content) + + # Back side → slot 2*i + 2 + _clear_children(paper.back_content) + var back_slot: int = 2 * i + 2 + var back_spell: int = back_slot - PLACEHOLDER_PAGES + if back_spell >= 0 and back_spell < learnt.size(): + _populate_spell_side(paper.back_content, learnt[back_spell], back_slot) + else: + _populate_placeholder(paper.back_content) + + # ── Restore paper turn states based on current_spread ── + for i in range(_papers.size()): + var paper = _papers[i] + if i < current_spread: + # Already turned + paper.material.set_shader_parameter("progress", 1.0) + paper.turned = true + paper.poly.z_index = i + 1 # turned papers stack in order + else: + # Not yet turned + paper.material.set_shader_parameter("progress", 0.0) + paper.turned = false + paper.poly.z_index = 100 - i # unturned stack: 0 on top + + +func _populate_spell_side(parent: Control, spell_id: String, absolute_index: int): + ## Build a spell entry (icon, name, description, hotkey slots) inside `parent`. + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 8) + margin.add_theme_constant_override("margin_right", 8) + margin.add_theme_constant_override("margin_top", 10) + margin.add_theme_constant_override("margin_bottom", 10) + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + parent.add_child(margin) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 6) + margin.add_child(vbox) + + # ── Icon ── + var icon_rect := TextureRect.new() + icon_rect.custom_minimum_size = Vector2(40, 40) + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + 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: int = 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 + vbox.add_child(icon_rect) + + # ── Name ── + var info = SPELL_INFO.get(spell_id, [spell_id.capitalize(), ""]) + var name_label := Label.new() + name_label.text = info[0] + 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)) + vbox.add_child(name_label) + + # ── Description ── + var desc_label := Label.new() + desc_label.text = info[1] + 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 + vbox.add_child(desc_label) + + # ── Hotkey slots ── + var slot_hbox := HBoxContainer.new() + slot_hbox.add_theme_constant_override("separation", 4) + var slot_labels: Array = [] + 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) + var spell_in_slot = character_stats.spell_hotkeys.get(slot, "") if character_stats else "" + slot_label.text = " [%s]" % slot + slot_label.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)) + slot_hbox.add_child(slot_label) + slot_labels.append(slot_label) + vbox.add_child(slot_hbox) + + # ── Click handling ── + # Wrap in a button-like behaviour + var click_area := Button.new() + click_area.flat = true + click_area.set_anchors_preset(Control.PRESET_FULL_RECT) + click_area.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + click_area.pressed.connect(_on_spell_clicked.bind(absolute_index)) + parent.add_child(click_area) + + # ── Store references for live updates ── + _spell_entries[absolute_index] = { + "icon": icon_rect, + "name_label": name_label, + "desc_label": desc_label, + "slot_labels": slot_labels, + "spell_id": spell_id, + "click_area": click_area, + } + + +func _on_spell_clicked(absolute_index: int): + if _is_animating: + return + # Determine which side this spell is on (left = 0, right = 1) + var spread_left_idx: int = current_spread * 2 + var spread_right_idx: int = current_spread * 2 + 1 + if absolute_index == spread_left_idx: + selected_spell_index = 0 + elif absolute_index == spread_right_idx: + selected_spell_index = 1 + else: + selected_spell_index = -1 + _update_selection_highlight() + + +func _update_selection_highlight(): + ## Update visual highlight on the selected spell entry. + var spread_left_idx: int = current_spread * 2 + var spread_right_idx: int = current_spread * 2 + 1 + for idx in _spell_entries.keys(): + var entry = _spell_entries[idx] + if not is_instance_valid(entry.name_label): + _spell_entries.erase(idx) + continue + var is_selected: bool = false + if selected_spell_index == 0 and idx == spread_left_idx: + is_selected = true + elif selected_spell_index == 1 and idx == spread_right_idx: + is_selected = true + entry.name_label.add_theme_color_override("font_color", + Color(0.55, 0.25, 0.05) if is_selected else Color(0.1, 0.08, 0.04)) + + +func _update_hotkey_labels(): + ## Refresh hotkey slot colours on all visible entries. + if not character_stats: + return + for idx in _spell_entries.keys(): + var entry = _spell_entries[idx] + # Skip entries whose nodes have been freed (e.g. from static page rebuild) + if not is_instance_valid(entry.name_label): + _spell_entries.erase(idx) + continue + var spell_id: String = entry.spell_id + for s in range(3): + var slot = str(s + 1) + var spell_in_slot = character_stats.spell_hotkeys.get(slot, "") + var label = entry.slot_labels[s] + if not is_instance_valid(label): + continue + label.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)) + + +func _random_gibberish(min_words: int, max_words: int) -> String: + ## Generate unreadable gibberish text as if the player can't decipher it. + var consonants := "bcdfghjklmnpqrstvwxz" + var vowels := "aeiou" + var word_count: int = randi_range(min_words, max_words) + var words: PackedStringArray = [] + for _w in range(word_count): + var length: int = randi_range(3, 9) + var word := "" + for j in range(length): + if j % 2 == 0: + word += consonants[randi_range(0, consonants.length() - 1)] + else: + word += vowels[randi_range(0, vowels.length() - 1)] + words.append(word) + return " ".join(words) + + +func _populate_placeholder(parent: Control): + ## Fill an empty page with a placeholder icon and gibberish text. + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 8) + margin.add_theme_constant_override("margin_right", 8) + margin.add_theme_constant_override("margin_top", 10) + margin.add_theme_constant_override("margin_bottom", 10) + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + parent.add_child(margin) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 6) + margin.add_child(vbox) + + # Placeholder icon (frame 122 from shade_spell_effects.png) + var icon_rect := TextureRect.new() + icon_rect.custom_minimum_size = Vector2(40, 40) + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + 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: int = 122 % SPELL_ICON_HFRAMES + @warning_ignore("INTEGER_DIVISION") + var row := int(122 / SPELL_ICON_HFRAMES) + atlas.region = Rect2(col * cw, row * ch, cw, ch) + icon_rect.texture = atlas + vbox.add_child(icon_rect) + + # Gibberish title — the player can't read this yet + var title := Label.new() + title.text = _random_gibberish(2, 3) + title.add_theme_font_size_override("font_size", 13) + title.add_theme_color_override("font_color", Color(0.3, 0.25, 0.18)) + vbox.add_child(title) + + # Gibberish body + var body := Label.new() + body.text = _random_gibberish(12, 18) + body.add_theme_font_size_override("font_size", 9) + body.add_theme_color_override("font_color", Color(0.35, 0.3, 0.22)) + body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(body) + + +func _clear_children(node: Control): + if not node or not is_instance_valid(node): + return + for c in node.get_children(): + c.queue_free() + + +# ══════════════════════════════════════════════════════════════════ +# Cover opening 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 + _is_animating = true + + # Reset cover to closed + if _cover_poly: + _cover_poly.visible = true + _cover_mat.set_shader_parameter("progress", 0.0) + + # Reset left mask to cover the left side + if _left_mask: + _left_mask.visible = true + _left_mask.set_size(Vector2(PAGE_WIDTH + SPINE_WIDTH, BOOK_HEIGHT)) + + # Pages and binding are visible but hidden behind the cover + left mask + _left_page_bg.modulate.a = 1.0 + if _right_page_bg: + _right_page_bg.modulate.a = 1.0 + if _spine_rect: + _spine_rect.modulate.a = 1.0 + + # Hide controls until after opening if _btn_hbox: _btn_hbox.modulate.a = 0.0 if _hint_label: _hint_label.modulate.a = 0.0 + + # Hide paper pages during opening cascade + for p in _papers: + p.poly.visible = false + _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) + + # ── One continuous right-to-left reveal ── + # Phase 1: Cover opens on the right (shader squeeze, 0→0.5) + # Phase 2: Left mask shrinks, revealing binding then left page + var phase1_dur: float = COVER_OPEN_DURATION * 0.5 + var phase2_dur: float = COVER_OPEN_DURATION * 0.5 + + _cover_tween = create_tween() + + # Brief pause to show the closed cover before opening + _cover_tween.tween_interval(0.2) + + # Phase 1: cover opens + var cover_sub := _cover_tween.tween_method(_set_cover_progress, 0.0, 0.5, phase1_dur) + cover_sub.set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC) + + _cover_tween.tween_callback(func(): + _cover_poly.visible = false + ) + + # Phase 2: left mask shrinks width from (PAGE_WIDTH+SPINE_WIDTH) → 0 + # Reveals from right to left: binding first, then left page + var mask_sub := _cover_tween.tween_property(_left_mask, "size:x", 0.0, phase2_dur) + mask_sub.set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC) + + _cover_tween.tween_callback(func(): + _left_mask.visible = false + ) + + _cover_tween.tween_interval(COVER_PAUSE) + _cover_tween.tween_callback(_start_opening_cascade) -# ══════════════════════════════════════════════════════════════════ -# 4-layer flip overlay -# ══════════════════════════════════════════════════════════════════ +func _set_cover_progress(val: float): + if _cover_mat: + _cover_mat.set_shader_parameter("progress", val) -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): +func _start_opening_cascade(): + ## Quick decorative page flips after the cover opens. + # Show pages for the cascade + for p in _papers: + p.poly.visible = true + p.material.set_shader_parameter("progress", 0.0) + p.turned = false + + _cascade_index = 0 + _run_next_cascade_flip() + + +func _run_next_cascade_flip(): + if _cascade_index >= mini(CASCADE_FLIPS, _papers.size()): + _on_cascade_finished() 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 + + var paper = _papers[_cascade_index] + # Variable timing: fast in the middle, slower at edges + var t: float = float(_cascade_index) / float(maxi(CASCADE_FLIPS - 1, 1)) + var speed_factor: float = sin(t * PI) + var duration: float = lerpf(CASCADE_MAX_DUR, CASCADE_MIN_DUR, speed_factor) + + _page_tween = create_tween() + _page_tween.set_ease(Tween.EASE_IN_OUT) + _page_tween.set_trans(Tween.TRANS_SINE) + _page_tween.tween_method(func(v): paper.material.set_shader_parameter("progress", v), 0.0, 1.0, duration) + _page_tween.tween_callback(func(): + paper.turned = true + paper.poly.z_index = _cascade_index + 1 + _cascade_index += 1 + _run_next_cascade_flip() + ) + + +func _on_cascade_finished(): + ## After cascade, land on the spread where real spells begin. + ## The first spell is at slot PLACEHOLDER_PAGES; that's the left page of + ## spread (PLACEHOLDER_PAGES / 2). Keep cascade papers turned to that point. + ## Paper SubViewport content was already populated during refresh(), so we + ## only need to set turn states and update the static left page. + @warning_ignore("INTEGER_DIVISION") + var target_spread: int = PLACEHOLDER_PAGES / 2 + for i in range(_papers.size()): + if i < target_spread: + _papers[i].material.set_shader_parameter("progress", 1.0) + _papers[i].turned = true + _papers[i].poly.z_index = i + 1 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 + _papers[i].material.set_shader_parameter("progress", 0.0) + _papers[i].turned = false + _papers[i].poly.z_index = 100 - i + current_spread = target_spread + _update_static_pages() + _update_nav_buttons() - -# ══════════════════════════════════════════════════════════════════ -# 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) + # Pages and binding are already visible; just fade in controls + var fade := create_tween() + fade.set_ease(Tween.EASE_OUT) + fade.set_trans(Tween.TRANS_CUBIC) + fade.set_parallel(true) if _btn_hbox: - fade_tween.tween_property(_btn_hbox, "modulate:a", 1.0, CONTENT_FADE_DURATION) + fade.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) + fade.tween_property(_hint_label, "modulate:a", 1.0, CONTENT_FADE_DURATION) + # Show the topmost unturned paper + for p in _papers: + p.poly.visible = true + fade.set_parallel(false) + fade.tween_callback(func(): + _is_animating = false + _update_nav_buttons() + ) # ══════════════════════════════════════════════════════════════════ -# Utility +# Page navigation # ══════════════════════════════════════════════════════════════════ -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 _on_next(): + if _is_animating: + return + if current_spread >= _total_spreads - 1: + return + if _papers.size() == 0: + return + + # The paper to turn is the one at index current_spread + var paper_idx: int = current_spread + if paper_idx >= _papers.size(): + return + + _is_animating = true + selected_spell_index = -1 + var paper = _papers[paper_idx] + + # Paper leaves the right side → prepare the RIGHT page content now. + # The LEFT page is covered by the turning paper's back, so update it after. + current_spread += 1 + _update_static_right_only() + _update_nav_buttons() + + _page_tween = create_tween() + _page_tween.set_ease(Tween.EASE_IN_OUT) + _page_tween.set_trans(Tween.TRANS_SINE) + _page_tween.tween_method(func(v): paper.material.set_shader_parameter("progress", v), 0.0, 1.0, PAGE_TURN_DURATION) + _page_tween.tween_callback(func(): + paper.turned = true + paper.poly.z_index = paper_idx + 1 + _is_animating = false + _update_static_left_only() + _update_nav_buttons() + _update_selection_highlight() + ) + + +func _on_prev(): + if _is_animating: + return + if current_spread <= 0: + return + + # The paper to un-turn is the one at index current_spread - 1 + var paper_idx: int = current_spread - 1 + if paper_idx < 0 or paper_idx >= _papers.size(): + return + + _is_animating = true + selected_spell_index = -1 + var paper = _papers[paper_idx] + + # Paper swings back to the right → prepare the LEFT page content now. + # The RIGHT page is covered by the incoming paper's front, so update it after. + current_spread -= 1 + _update_static_left_only() + _update_nav_buttons() + + _page_tween = create_tween() + _page_tween.set_ease(Tween.EASE_IN_OUT) + _page_tween.set_trans(Tween.TRANS_SINE) + _page_tween.tween_method(func(v): paper.material.set_shader_parameter("progress", v), 1.0, 0.0, PAGE_TURN_DURATION) + _page_tween.tween_callback(func(): + paper.turned = false + paper.poly.z_index = 100 - paper_idx + _is_animating = false + _update_static_right_only() + _update_nav_buttons() + _update_selection_highlight() + ) + + +func _update_static_pages(): + ## Update both static page contents for the current spread. + _update_static_left_only() + _update_static_right_only() + + +func _update_static_left_only(): + ## Update just the static left page for current_spread. + var learnt: Array = character_stats.learnt_spells if character_stats else [] + _clear_children(_left_page_content) + var left_slot: int = current_spread * 2 + var left_spell: int = left_slot - PLACEHOLDER_PAGES + if left_spell >= 0 and left_spell < learnt.size(): + _populate_spell_side(_left_page_content, learnt[left_spell], left_slot) + else: + _populate_placeholder(_left_page_content) + + +func _update_static_right_only(): + ## Update just the static right page for current_spread. + var learnt: Array = character_stats.learnt_spells if character_stats else [] + _clear_children(_right_page_content) + var right_slot: int = current_spread * 2 + 1 + var right_spell: int = right_slot - PLACEHOLDER_PAGES + if right_spell >= 0 and right_spell < learnt.size(): + _populate_spell_side(_right_page_content, learnt[right_spell], right_slot) + else: + _populate_placeholder(_right_page_content) -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) + _prev_btn.visible = _total_spreads > 1 + _prev_btn.disabled = (current_spread <= 0) or _is_animating if _next_btn: - _next_btn.visible = total_pages_val > 1 - _next_btn.disabled = (current_page >= total_pages_val - 1) + _next_btn.visible = _total_spreads > 1 + _next_btn.disabled = (current_spread >= _total_spreads - 1) or _is_animating # ══════════════════════════════════════════════════════════════════ -# Spell slot assignment +# Hotkey 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(): + var slot_idx: int = current_spread * 2 + selected_spell_index + var spell_idx: int = slot_idx - PLACEHOLDER_PAGES + if spell_idx < 0 or spell_idx >= learnt.size(): return - var spell_id: String = learnt[idx] + var spell_id: String = learnt[spell_idx] character_stats.spell_hotkeys[slot] = spell_id character_stats.emit_signal("character_changed", character_stats) - _update_page_content() + _update_hotkey_labels() # ══════════════════════════════════════════════════════════════════ -# Layout fitting +# Layout # ══════════════════════════════════════════════════════════════════ 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 total_width: int = PAGE_WIDTH + SPINE_WIDTH + PAGE_WIDTH var w: float = _book_wrapper.size.x var h: float = _book_wrapper.size.y if w <= 0 or h <= 0: @@ -519,305 +1059,37 @@ func _fit_book_in_view() -> void: # ══════════════════════════════════════════════════════════════════ -# Page navigation +# Reset / cleanup # ══════════════════════════════════════════════════════════════════ -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 +func _reset_to_closed(): + ## Reset everything to the closed-book state. + if _cover_poly: + _cover_poly.visible = true + _cover_mat.set_shader_parameter("progress", 0.0) + if _left_mask: + _left_mask.visible = true + _left_mask.set_size(Vector2(PAGE_WIDTH + SPINE_WIDTH, BOOK_HEIGHT)) + for p in _papers: + if p.poly and is_instance_valid(p.poly): + p.material.set_shader_parameter("progress", 0.0) + p.turned = false + p.poly.visible = true + _left_page_bg.modulate.a = 1.0 + if _right_page_bg: + _right_page_bg.modulate.a = 1.0 + if _spine_rect: + _spine_rect.modulate.a = 1.0 + if _btn_hbox: + _btn_hbox.modulate.a = 1.0 + if _hint_label: + _hint_label.modulate.a = 1.0 + current_spread = 0 + _is_animating = false -# ══════════════════════════════════════════════════════════════════ -# 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 +func _kill_all_tweens(): + if _cover_tween and _cover_tween.is_valid(): + _cover_tween.kill() + if _page_tween and _page_tween.is_valid(): + _page_tween.kill() diff --git a/src/scripts/test_spellbook.gd b/src/scripts/test_spellbook.gd new file mode 100644 index 0000000..2b65db1 --- /dev/null +++ b/src/scripts/test_spellbook.gd @@ -0,0 +1,17 @@ +extends Control +## Standalone test harness for the SpellBook UI. +## Run this scene directly (F6) to see the spellbook open and turn pages. + +@onready var spell_book: Control = $SpellBookPanel + + +func _ready(): + # Create a mock CharacterStats with all five spells learnt. + var stats := CharacterStats.new() + stats.learnt_spells = ["flames", "frostspike", "healing", "water_bubble", "earth_spike"] + stats.spell_hotkeys = {"1": "", "2": "", "3": ""} + + # Give the UI one frame to finish _ready(), then feed it the stats. + await get_tree().process_frame + spell_book.visible = true + spell_book.set_character_stats(stats) diff --git a/src/scripts/test_spellbook.gd.uid b/src/scripts/test_spellbook.gd.uid new file mode 100644 index 0000000..85d01d5 --- /dev/null +++ b/src/scripts/test_spellbook.gd.uid @@ -0,0 +1 @@ +uid://cyqdk71cnqipt diff --git a/src/shaders/page_turn.gdshader b/src/shaders/page_turn.gdshader new file mode 100644 index 0000000..46f1e4f --- /dev/null +++ b/src/shaders/page_turn.gdshader @@ -0,0 +1,44 @@ +shader_type canvas_item; + +// How far the page turn has progressed (0 = flat on right, 1 = flat on left). +uniform float progress : hint_range(0.0, 1.0) = 0.0; + +// Page dimensions in pixels (set from script). +uniform float page_width = 182.0; +uniform float page_height = 256.0; + +// How many pixels the free edge lifts upward during the turn. +uniform float arch_amount = 80.0; + +// Back side texture (from SubViewport). +uniform sampler2D back_texture : hint_default_white; + +// Pass normalized UV from vertex to fragment for back-texture sampling. +varying vec2 page_uv; + +void vertex() { + float nx = VERTEX.x / page_width; + + // Store normalized UV before deformation for texture lookups. + page_uv = vec2(VERTEX.x / page_width, VERTEX.y / page_height); + + // --- Horizontal: uniform sweep from right to left --- + VERTEX.x *= cos(PI * progress); + + // --- Vertical: page bends upward as it sweeps --- + float arch_envelope = sin(PI * progress); + float arch_shape = sqrt(nx); + float arch = arch_amount * arch_shape * arch_envelope; + VERTEX.y -= arch; +} + +void fragment() { + if (progress <= 0.5) { + // Front side visible. + COLOR = texture(TEXTURE, UV); + } else { + // Page has flipped past mid-point: show back side with mirrored UV. + vec2 back_uv = vec2(1.0 - page_uv.x, page_uv.y); + COLOR = texture(back_texture, back_uv); + } +} diff --git a/src/shaders/page_turn.gdshader.uid b/src/shaders/page_turn.gdshader.uid new file mode 100644 index 0000000..e25b9ce --- /dev/null +++ b/src/shaders/page_turn.gdshader.uid @@ -0,0 +1 @@ +uid://bdnrvvdqoocwn