1409 lines
52 KiB
GDScript
1409 lines
52 KiB
GDScript
@tool
|
|
class_name PageFlip2D
|
|
extends Node2D
|
|
|
|
## Main controller for the Book system. Handles animations, volume logic, and interactive scenes.
|
|
##
|
|
## [b]ADVANCED FEATURE: INTERACTIVE PAGE SCENES (INPUT HANDSHAKE)[/b][br]
|
|
## This script supports fully interactive scenes (UI, puzzles, maps) embedded as pages.[br]
|
|
## It automatically manages an "Input Handshake" to prevent conflicts between[br]
|
|
## the Book (turning pages) and the Scene (interaction).[br]
|
|
## [br]
|
|
## [b]LOGIC FLOW:[/b][br]
|
|
## - Uses 4 internal Viewport Slots.[br]
|
|
## - Slot 1: Static Left Page.[br]
|
|
## - Slot 2: Static Right Page.[br]
|
|
## - Slot 3: Dynamic Page Face A (Front).[br]
|
|
## - Slot 4: Dynamic Page Face B (Back).[br]
|
|
## - The Shader handles the visibility of Face A vs Face B automatically.
|
|
|
|
## Defines how textures are stretched within the page boundaries.
|
|
enum PageStretchOption {
|
|
## Scales the texture to fit the page rect, potentially distorting aspect ratio.
|
|
SCALE = TextureRect.STRETCH_SCALE,
|
|
## Keeps the aspect ratio and centers the texture, leaving blank space if necessary.
|
|
KEEP_ASPECT_CENTERED = TextureRect.STRETCH_KEEP_ASPECT_CENTERED,
|
|
## Keeps the aspect ratio but covers the entire page, potentially cropping the image.
|
|
KEEP_ASPECT_COVERED = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
|
}
|
|
|
|
## Defines the condition required to trigger the book closing sequence.
|
|
enum CloseCondition {
|
|
## The book will never close automatically.
|
|
NEVER,
|
|
## The book closes only when finishing the animation that shuts the back cover (Right to Left).
|
|
CLOSE_FROM_BACK,
|
|
## The book closes only when finishing the animation that shuts the front cover (Left to Right).
|
|
CLOSE_FROM_FRONT,
|
|
## The book closes when shutting either the front or the back cover.
|
|
ANY_CLOSE,
|
|
## The book closes immediately when the 'ui_cancel' action (e.g., ESC) is pressed.
|
|
ON_CANCEL_INPUT,
|
|
## The book closing logic is handled manually by an interactive scene (calling force_close_book).
|
|
DELEGATED
|
|
}
|
|
|
|
## Defines what happens to the Book node after it closes.
|
|
enum CloseBehavior {
|
|
## The Book node is deleted from the scene tree (queue_free).
|
|
DESTROY_BOOK,
|
|
## The game changes to a specific scene defined in 'target_scene_on_close'.
|
|
CHANGE_SCENE
|
|
}
|
|
|
|
## Defines the initial state of the book when the scene loads.
|
|
enum StartOption {
|
|
## The book starts fully closed showing the front cover.
|
|
CLOSED_FROM_FRONT,
|
|
## The book starts fully closed showing the back cover.
|
|
CLOSED_FROM_BACK,
|
|
## The book starts open at a specific page number defined in 'start_page'.
|
|
OPEN_AT_PAGE
|
|
}
|
|
|
|
## Defines the target destination for the go_to_page function.
|
|
enum JumpTarget {
|
|
## Jumps to a specific content page number (using the 'page_num' parameter).
|
|
CONTENT_PAGE,
|
|
## Jumps to the closed state showing the Front Cover.
|
|
FRONT_COVER,
|
|
## Jumps to the closed state showing the Back Cover.
|
|
BACK_COVER
|
|
}
|
|
|
|
|
|
@export_category("Newold Config")
|
|
## Apply the custom configuration defined by Newold (Size, Physics, Layers).
|
|
## Clicking this will overwrite current physics and sizing settings with a beauty starting preset.
|
|
@export var apply_newold_preset: bool = false : set = _apply_newold_config
|
|
|
|
|
|
# ==============================================================================
|
|
# 1. REFERENCES & CONFIGURATION
|
|
# ==============================================================================
|
|
@export_category("Structure References")
|
|
## Container for all book visuals (Pages, Covers, Spine).
|
|
@export var visuals_container: Node2D
|
|
## Static polygon representing the left page (Slot 1).
|
|
@export var static_left: Polygon2D
|
|
## Static polygon representing the right page (Slot 2).
|
|
@export var static_right: Polygon2D
|
|
## Dynamic polygon used for the turning page animation (Slot 3 & 4).
|
|
@export var dynamic_poly: Polygon2D
|
|
## Animation player handling the page flip sequences.
|
|
@export var anim_player: AnimationPlayer
|
|
|
|
|
|
@export_category("Book Logic & Closing")
|
|
## Determines the initial state of the book (closed or open at specific page).
|
|
@export var start_option: StartOption = StartOption.CLOSED_FROM_FRONT : set = _set_start_option
|
|
## The page number to open at startup. Only visible if start_option is OPEN_AT_PAGE.[br][br]
|
|
## If any of the pages that remain open are interactive scenes,
|
|
## the script will automatically transfer control to them.[br][br]
|
|
## Note: Since an even page (Left) and the next odd page (Right) share the same spread,
|
|
## selecting either of them will result in the book opening to the same visual state.
|
|
@export var start_page: int = 1
|
|
## Determines when the book should perform the close action (e.g., destroy or change scene).
|
|
@export var close_condition: CloseCondition = CloseCondition.NEVER
|
|
## Determines what happens when the book closes (Destroy or Change Scene).
|
|
@export var close_behavior: CloseBehavior = CloseBehavior.DESTROY_BOOK : set = _set_close_behavior
|
|
## The file path to the scene to load if 'Change Scene' is selected as behavior.
|
|
@export_file("*.tscn") var target_scene_on_close: String
|
|
## If true, pages with transparency will be composited over the 'Blank Page' texture/color.
|
|
## Useful for PNG notes or decals that need a paper background.
|
|
@export var enable_composite_pages: bool = false
|
|
|
|
|
|
@export_category("Styling & Spine")
|
|
## Base color for pages without texture content or the background of composite pages.
|
|
@export var blank_page_color: Color = Color.WHITE
|
|
## Texture used when a page path is invalid, empty, or for the background of composite pages.
|
|
@export var blank_page_texture: Texture2D
|
|
## Color tint for the central spine of the book.
|
|
@export var spine_color: Color = Color(1, 1, 1) : set = _set_spine_color
|
|
## Texture for the central spine of the book.
|
|
@export var spine_texture: Texture2D : set = _set_spine_texture
|
|
## Width of the central spine in pixels.
|
|
@export var spine_width: float = 40.0 : set = _set_spine_value
|
|
## Determines if the front and back covers behave as rigid bodies (hard cover) or flexible (soft cover).
|
|
@export var covers_are_rigid: bool = true
|
|
|
|
|
|
# ==============================================================================
|
|
# FAKE 3D TRANSFORM SETTINGS
|
|
# ==============================================================================
|
|
@export_category("Fake 3D (Transform)")
|
|
@export_group("Closed State")
|
|
## Scale of the book container when fully closed.
|
|
@export var closed_scale: Vector2 = Vector2(1.0, 0.85)
|
|
## Skew (shear) applied when closed to simulate perspective or isometric view.
|
|
@export_range(-1.0, 1.0) var closed_skew: float = 0.0
|
|
## Rotation (degrees) of the book when closed.
|
|
@export_range(-360.0, 360.0) var closed_rotation: float = 0.0
|
|
|
|
@export_group("Animation")
|
|
## Speed multiplier for the page flip animation. Higher is faster.
|
|
@export var flip_speed_scale: float = 1.0 :
|
|
set(val):
|
|
flip_speed_scale = val
|
|
if anim_player:
|
|
anim_player.speed_scale = val
|
|
|
|
@export var use_internal_camera: bool = true
|
|
@export_group("Open State")
|
|
## Scale of the book container when fully open.
|
|
@export var open_scale: Vector2 = Vector2(1.0, 1.0)
|
|
## Skew (shear) applied when open.
|
|
@export var open_skew: float = 0.0
|
|
## Rotation (degrees) of the book when open.
|
|
@export var open_rotation: float = 0.0
|
|
|
|
|
|
# ==============================================================================
|
|
# FAKE 3D VOLUME (PAGES)
|
|
# ==============================================================================
|
|
@export_category("Fake 3D Volume (Pages)")
|
|
## Minimum number of layers to generate for the page stack (visual thickness floor).
|
|
@export_range(1, 20) var min_layers: int = 3
|
|
## Maximum number of layers to generate for the page stack (visual thickness ceiling).
|
|
@export_range(1, 100) var max_layers: int = 15
|
|
## If true, the stack grows upwards (negative Y). If false, downwards (positive Y).
|
|
@export var invert_stack_direction: bool = false
|
|
## Offset between layers when the book is closed (max compression).
|
|
@export var layer_offset_closed: Vector2 = Vector2(0, 1.5)
|
|
## Offset between layers when the book is open (relaxed compression).
|
|
@export var layer_offset_open: Vector2 = Vector2(0, 0.2)
|
|
## Base color tint for the volume layers (paper edges).
|
|
@export var volume_color: Color = Color(0.8, 0.75, 0.6)
|
|
## Darkening factor for alternating layers to create a paper texture effect.
|
|
@export_range(0.0, 1.0) var stripe_darken_ratio: float = 0.85
|
|
## Global positional offset for the entire volume stack.
|
|
@export var volume_stack_offset: Vector2 = Vector2.ZERO
|
|
## Time in seconds before the animation ends to "land" the page on the stack visually.
|
|
@export var landing_overlap: float = 0.15
|
|
|
|
|
|
@export_category("Audio")
|
|
## Sound played when a rigid cover slams shut.
|
|
@export var sfx_book_impact: AudioStream
|
|
## Sound played when a flexible page turns.
|
|
@export var sfx_page_flip: AudioStream
|
|
## Time offset to sync the impact sound with the visual contact frame.
|
|
@export var impact_sync_offset: float = 0.15
|
|
## AudioStreamPlayer node used for feedback.
|
|
@export var audio_player: AudioStreamPlayer
|
|
|
|
|
|
@export_category("Book Size Control")
|
|
## Target size (Resolution) for the book pages. Affects Viewports and Meshes.
|
|
@export var target_page_size: Vector2 = Vector2(512, 820)
|
|
## Button to apply size changes in the editor.
|
|
@export var apply_size_change: bool = false : set = _on_apply_size_pressed
|
|
|
|
|
|
@export_category("Content Source")
|
|
## List of paths to textures (*.png, *.jpg) or PackedScenes (*.tscn) for pages.
|
|
@export_file("*.png", "*.jpg", "*.jpeg", "*.tscn") var pages_paths: Array[String] = []
|
|
## Determines how standard images (textures) are stretched within the page boundaries.
|
|
@export var page_stretch_mode: PageStretchOption = PageStretchOption.SCALE
|
|
## External front cover texture.
|
|
@export var tex_cover_front_out: Texture2D
|
|
## Internal front cover texture.
|
|
@export var tex_cover_front_in: Texture2D
|
|
## Internal back cover texture.
|
|
@export var tex_cover_back_in: Texture2D
|
|
## External back cover texture.
|
|
@export var tex_cover_back_out: Texture2D
|
|
|
|
|
|
# ==============================================================================
|
|
# 2. INTERNAL STATE
|
|
# ==============================================================================
|
|
var current_spread: int = -1
|
|
var total_spreads: int = 0
|
|
var is_animating: bool = false
|
|
var going_forward: bool = true
|
|
var page_width: float
|
|
var is_book_open: bool = false
|
|
|
|
var _runtime_pages: Array[String] = []
|
|
var _spine_poly: Polygon2D
|
|
|
|
# Slot References (Viewports)
|
|
var _slot_1: SubViewport # Left Page
|
|
var _slot_2: SubViewport # Right Page
|
|
var _slot_3: SubViewport # Animation Face A
|
|
var _slot_4: SubViewport # Animation Face B
|
|
|
|
var _active_interactive_is_left: bool = false
|
|
var _active_interactive_is_right: bool = false
|
|
var _scene_cache = {}
|
|
|
|
# Internal references for volume generation
|
|
var _volume_root: Node2D
|
|
var _current_expansion_factor: float = 0.0
|
|
var _visual_spread_index: float = -1.0
|
|
var _stack_scale_left: float = 1.0
|
|
var _stack_scale_right: float = 1.0
|
|
|
|
# Animation flags
|
|
var _is_page_flying: bool = false
|
|
var _flying_from_right: bool = false
|
|
var _force_hide_vol_left: bool = false
|
|
var _force_hide_vol_right: bool = false
|
|
var _pending_target_spread_idx: int = 0
|
|
|
|
# Flag to handle forced closing animation state (API force_close_book)
|
|
var _is_force_closing: bool = false
|
|
|
|
# Flags to handle jump-to-page logic
|
|
var _is_jumping: bool = false
|
|
var _jump_target_spread: int = 0
|
|
|
|
## Tracks which viewports have received the mouse_entered notification.
|
|
var _active_viewports_state: Dictionary = {}
|
|
|
|
|
|
signal started_page_flip_animation()
|
|
signal ended_page_flip_animation()
|
|
|
|
|
|
func _validate_property(property: Dictionary) -> void:
|
|
if property.name == "target_scene_on_close":
|
|
if close_behavior != CloseBehavior.CHANGE_SCENE:
|
|
property.usage = PROPERTY_USAGE_NO_EDITOR
|
|
|
|
if property.name == "start_page":
|
|
if start_option != StartOption.OPEN_AT_PAGE:
|
|
property.usage = PROPERTY_USAGE_NO_EDITOR
|
|
|
|
|
|
func _set_close_behavior(value):
|
|
close_behavior = value
|
|
notify_property_list_changed()
|
|
|
|
|
|
func _set_start_option(value):
|
|
start_option = value
|
|
notify_property_list_changed()
|
|
|
|
|
|
func _apply_newold_config(val):
|
|
if not val: return
|
|
apply_newold_preset = false
|
|
print("[BookController] Applying 'Newold' configuration...")
|
|
blank_page_color = Color.WHITE
|
|
blank_page_texture = preload("uid://cen51wqc15b14")
|
|
spine_color = Color.WHITE
|
|
spine_texture = preload("uid://cit41jypw2sy1")
|
|
spine_width = 12.0
|
|
covers_are_rigid = true
|
|
closed_scale = Vector2(0.815, 0.45)
|
|
closed_skew = 0.05
|
|
closed_rotation = 0.1
|
|
open_scale = Vector2(1.0, 0.975)
|
|
open_skew = 0.0
|
|
open_rotation = 0.0
|
|
min_layers = 1
|
|
max_layers = 15
|
|
invert_stack_direction = false
|
|
layer_offset_closed = Vector2(3.0, 6.5)
|
|
layer_offset_open = Vector2(4.0, 2.0)
|
|
volume_color = Color("#a6978f")
|
|
stripe_darken_ratio = 0.848
|
|
volume_stack_offset = Vector2(0, 0)
|
|
landing_overlap = 0.15
|
|
sfx_book_impact = preload("uid://bhitebdghyhua")
|
|
sfx_page_flip = preload("uid://bylfc3b5pmbij")
|
|
impact_sync_offset = 0.15
|
|
target_page_size = Vector2(512, 820)
|
|
page_stretch_mode = PageStretchOption.SCALE
|
|
tex_cover_front_out = preload("uid://dbdwbowx32d3v")
|
|
tex_cover_front_in = preload("uid://dt1tiecgw5rip")
|
|
tex_cover_back_in = preload("uid://dt1tiecgw5rip")
|
|
tex_cover_back_out = preload("uid://bjowpx1ap4uxt")
|
|
_apply_new_size()
|
|
dynamic_poly.animation_preset = DynamicPage.PagePreset.LIGHT_MAGAZINE
|
|
notify_property_list_changed()
|
|
|
|
|
|
# ==============================================================================
|
|
# BUILD & INIT
|
|
# ==============================================================================
|
|
func __init():
|
|
var viewports_cont = __ensure_node("Viewports", Node, self)
|
|
var slots_cont = __ensure_node("Slots", Node, viewports_cont)
|
|
var visual_cont = __ensure_node("Visual", Node2D, self)
|
|
|
|
_slot_1 = __ensure_node("Slot1", SubViewport, slots_cont)
|
|
_slot_2 = __ensure_node("Slot2", SubViewport, slots_cont)
|
|
_slot_3 = __ensure_node("Slot3", SubViewport, slots_cont)
|
|
_slot_4 = __ensure_node("Slot4", SubViewport, slots_cont)
|
|
|
|
for slot in [_slot_1, _slot_2, _slot_3, _slot_4]:
|
|
slot.transparent_bg = true
|
|
slot.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
slot.size = target_page_size
|
|
if slot in [_slot_1, _slot_2]:
|
|
slot.physics_object_picking = true
|
|
|
|
var s_left = __ensure_node("StaticPageLeft", Polygon2D, visual_cont)
|
|
s_left.z_index = 1
|
|
var s_right = __ensure_node("StaticPageRight", Polygon2D, visual_cont)
|
|
s_right.z_index = 1
|
|
var d_poly = __ensure_node("DynamicFlipPoly", Polygon2D, visual_cont)
|
|
d_poly.z_index = 10
|
|
d_poly.clip_children = Control.ClipChildrenMode.CLIP_CHILDREN_AND_DRAW
|
|
|
|
if d_poly.get_script() == null:
|
|
d_poly.set_script(load("res://addons/PageFlip/page_rigger.gd"))
|
|
|
|
var anim = __ensure_node("AnimationPlayer", AnimationPlayer, self)
|
|
if use_internal_camera:
|
|
__ensure_node("Camera2D", Camera2D, self)
|
|
else:
|
|
var cam = get_node_or_null("Camera2D")
|
|
if cam: cam.queue_free()
|
|
var stream_player = __ensure_node("AudioStreamPlayer", AudioStreamPlayer, self)
|
|
|
|
if not visuals_container: visuals_container = visual_cont
|
|
if not static_left: static_left = s_left
|
|
if not static_right: static_right = s_right
|
|
if not dynamic_poly: dynamic_poly = d_poly
|
|
if not anim_player: anim_player = anim
|
|
if not audio_player: audio_player = stream_player
|
|
|
|
if dynamic_poly and anim_player:
|
|
if dynamic_poly.get("anim_player") == null:
|
|
dynamic_poly.set("anim_player", anim_player)
|
|
|
|
__ensure_node("RuntimeSpine", Polygon2D, visual_cont)
|
|
|
|
if Vector2(_slot_1.size) != target_page_size:
|
|
_apply_new_size()
|
|
|
|
|
|
func __ensure_node(target_name: String, type: Variant, parent_node: Node) -> Node:
|
|
var node = parent_node.get_node_or_null(target_name)
|
|
if node: return node
|
|
node = type.new()
|
|
node.name = target_name
|
|
if node is SubViewport: node.transparent_bg = true
|
|
parent_node.add_child(node)
|
|
if Engine.is_editor_hint(): node.owner = parent_node.owner if parent_node.owner else self
|
|
return node
|
|
|
|
|
|
func _enter_tree() -> void:
|
|
if not is_in_group("FlipBook2D"):
|
|
add_to_group("FlipBook2D")
|
|
BookAPI.set_current_book(self)
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
if BookAPI.get_current_book() == self:
|
|
BookAPI.set_current_book(null)
|
|
|
|
|
|
func _ready():
|
|
BookAPI.set_current_book(self)
|
|
if not _slot_1: _slot_1 = find_child("Slot1", true, false)
|
|
if not _slot_2: _slot_2 = find_child("Slot2", true, false)
|
|
if not _slot_3: _slot_3 = find_child("Slot3", true, false)
|
|
if not _slot_4: _slot_4 = find_child("Slot4", true, false)
|
|
|
|
if Engine.is_editor_hint():
|
|
__init()
|
|
if dynamic_poly: dynamic_poly.rebuild(target_page_size)
|
|
return
|
|
elif Vector2(_slot_1.size) != target_page_size:
|
|
_apply_new_size()
|
|
|
|
if dynamic_poly and not dynamic_poly.material:
|
|
dynamic_poly.material = preload("uid://ddw2ie8wnrnre")
|
|
|
|
if anim_player:
|
|
anim_player.speed_scale = flip_speed_scale
|
|
|
|
if dynamic_poly: dynamic_poly.rebuild(target_page_size)
|
|
|
|
if not blank_page_texture:
|
|
var w = int(target_page_size.x)
|
|
var h = int(target_page_size.y)
|
|
if w > 0 and h > 0:
|
|
var img = Image.create(w, h, false, Image.FORMAT_RGBA8)
|
|
img.fill(blank_page_color)
|
|
blank_page_texture = ImageTexture.create_from_image(img)
|
|
|
|
_prepare_book_content()
|
|
|
|
# Initial State Logic
|
|
if not Engine.is_editor_hint():
|
|
match start_option:
|
|
StartOption.CLOSED_FROM_FRONT:
|
|
current_spread = -1
|
|
StartOption.CLOSED_FROM_BACK:
|
|
current_spread = total_spreads
|
|
StartOption.OPEN_AT_PAGE:
|
|
var spread_idx = int(floor((start_page - 1) / 2.0))
|
|
current_spread = clampi(spread_idx, 0, total_spreads - 1)
|
|
|
|
if dynamic_poly and not dynamic_poly.is_connected("change_page_requested", _on_midpoint_signal):
|
|
dynamic_poly.connect("change_page_requested", _on_midpoint_signal)
|
|
|
|
if anim_player and not anim_player.is_connected("animation_finished", _on_animation_finished):
|
|
anim_player.animation_finished.connect(_on_animation_finished)
|
|
|
|
await get_tree().process_frame
|
|
|
|
if _slot_3 and dynamic_poly:
|
|
dynamic_poly.texture = _slot_3.get_texture()
|
|
|
|
_initial_config()
|
|
_set_flying_slots_active(false)
|
|
|
|
|
|
func _set_spine_color(color: Color) -> void:
|
|
spine_color = color
|
|
var node = find_child("RuntimeSpine")
|
|
if node: node.color = spine_color
|
|
|
|
|
|
func _set_spine_texture(texture: Texture2D) -> void:
|
|
spine_texture = texture
|
|
var node = find_child("RuntimeSpine")
|
|
if node: node.texture = spine_texture
|
|
|
|
|
|
func _set_spine_value(value: float) -> void:
|
|
spine_width = value
|
|
if visuals_container:
|
|
_build_spine()
|
|
|
|
|
|
func _initial_config():
|
|
page_width = target_page_size.x
|
|
var screen_center = _get_screen_center()
|
|
|
|
_set_page_visible(dynamic_poly, false)
|
|
|
|
_build_spine()
|
|
_generate_volume_layers()
|
|
|
|
# Add an invisible Control node to block mouse clicks passing through the book to the UI behind it
|
|
var input_blocker = visuals_container.get_node_or_null("InputBlocker")
|
|
if not input_blocker:
|
|
input_blocker = Control.new()
|
|
input_blocker.name = "InputBlocker"
|
|
input_blocker.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
visuals_container.add_child(input_blocker)
|
|
visuals_container.move_child(input_blocker, 0)
|
|
|
|
input_blocker.position = Vector2(-target_page_size.x, -target_page_size.y / 2.0)
|
|
input_blocker.size = Vector2(target_page_size.x * 2.0, target_page_size.y)
|
|
_visual_spread_index = float(current_spread)
|
|
|
|
var is_closed = (current_spread == -1 or current_spread == total_spreads)
|
|
is_book_open = not is_closed
|
|
var is_back = (current_spread == total_spreads)
|
|
|
|
if is_closed:
|
|
var offset = _get_compensation_offset(true, is_back)
|
|
var base_offset = offset - Vector2(target_page_size.x / 2, 0.0)
|
|
if use_internal_camera:
|
|
visuals_container.global_position = screen_center + base_offset
|
|
else:
|
|
visuals_container.position = base_offset
|
|
_animate_container_transform(true, is_back, 0.0)
|
|
if is_back: _update_stack_direct(1.0, float(total_spreads))
|
|
else: _update_stack_direct(1.0, -1.0)
|
|
else:
|
|
var offset = _get_compensation_offset(false, false)
|
|
var base_offset = offset - Vector2(target_page_size.x / 2, 0.0)
|
|
if use_internal_camera:
|
|
visuals_container.global_position = screen_center + base_offset
|
|
else:
|
|
visuals_container.position = base_offset
|
|
_animate_container_transform(false, false, 0.0)
|
|
_update_stack_direct(0.0, float(current_spread))
|
|
|
|
_update_static_visuals_immediate()
|
|
_update_volume_visuals()
|
|
_check_scene_activation.call_deferred()
|
|
|
|
|
|
# ==============================================================================
|
|
# VOLUME
|
|
# ==============================================================================
|
|
func _generate_volume_layers():
|
|
if _volume_root and is_instance_valid(_volume_root): _volume_root.queue_free()
|
|
var final_layer_count = 0
|
|
if total_spreads > 0: final_layer_count = clampi(total_spreads, min_layers, max_layers)
|
|
if final_layer_count <= 0: return
|
|
|
|
_volume_root = Node2D.new()
|
|
_volume_root.name = "VolumeStackPages"
|
|
visuals_container.add_child(_volume_root)
|
|
visuals_container.move_child(_volume_root, 0)
|
|
|
|
for i in range(final_layer_count):
|
|
var layer_node = Node2D.new()
|
|
layer_node.name = "Layer_%d" % i
|
|
var layer_col = volume_color
|
|
if i % 2 != 0:
|
|
layer_col.r *= stripe_darken_ratio
|
|
layer_col.g *= stripe_darken_ratio
|
|
layer_col.b *= stripe_darken_ratio
|
|
layer_node.modulate = layer_col
|
|
var s_left = static_left.duplicate()
|
|
var s_right = static_right.duplicate()
|
|
s_left.position = Vector2.ZERO; s_right.position = Vector2.ZERO
|
|
s_left.set_script(null); s_right.set_script(null)
|
|
layer_node.add_child(s_left)
|
|
layer_node.add_child(s_right)
|
|
_volume_root.add_child(layer_node)
|
|
|
|
|
|
func _tween_expansion_only(factor: float):
|
|
_update_stack_direct(factor, _visual_spread_index)
|
|
|
|
|
|
func _update_stack_direct(expansion_factor: float, visual_spread: float):
|
|
_current_expansion_factor = expansion_factor
|
|
_visual_spread_index = visual_spread
|
|
if not _volume_root: return
|
|
var current_step = layer_offset_open.lerp(layer_offset_closed, expansion_factor)
|
|
var total_layers = _volume_root.get_child_count()
|
|
var y_dir = -1.0 if invert_stack_direction else 1.0
|
|
|
|
var count_left = 0
|
|
var count_right = 0
|
|
|
|
if is_animating:
|
|
var layers_at_start = _get_layer_count_for_spread(current_spread, total_layers)
|
|
var layers_at_target = _get_layer_count_for_spread(_pending_target_spread_idx, total_layers)
|
|
if going_forward:
|
|
count_left = layers_at_start
|
|
count_right = total_layers - layers_at_target
|
|
else:
|
|
count_left = layers_at_target
|
|
count_right = total_layers - layers_at_start
|
|
|
|
var L_start_is_thin = (layers_at_start == 0)
|
|
var L_target_is_thin = (layers_at_target == 0)
|
|
if L_start_is_thin != L_target_is_thin: count_left = max(layers_at_start, layers_at_target)
|
|
|
|
var R_start_vol = total_layers - layers_at_start
|
|
var R_target_vol = total_layers - layers_at_target
|
|
var R_start_is_thin = (R_start_vol == 0)
|
|
var R_target_is_thin = (R_target_vol == 0)
|
|
if R_start_is_thin != R_target_is_thin: count_right = max(R_start_vol, R_target_vol)
|
|
else:
|
|
count_left = _get_layer_count_for_spread(visual_spread, total_layers)
|
|
count_right = total_layers - count_left
|
|
|
|
var force_hide_left = false
|
|
var force_hide_right = false
|
|
if _stack_scale_left <= 0.001: force_hide_left = true
|
|
if _stack_scale_right <= 0.001: force_hide_right = true
|
|
if _force_hide_vol_left: force_hide_left = true
|
|
if _force_hide_vol_right: force_hide_right = true
|
|
|
|
var left_threshold_idx = total_layers - count_left
|
|
var right_threshold_idx = total_layers - count_right
|
|
|
|
for i in range(total_layers):
|
|
var layer = _volume_root.get_child(i)
|
|
|
|
var depth_multiplier = float(total_layers - i)
|
|
|
|
var base_off_x = current_step.x * depth_multiplier
|
|
var base_off_y = (current_step.y * depth_multiplier) * y_dir
|
|
|
|
layer.position = Vector2.ZERO
|
|
var l_node = layer.get_child(0)
|
|
var r_node = layer.get_child(1)
|
|
|
|
var final_off_left = Vector2(-base_off_x, base_off_y) * _stack_scale_left
|
|
var final_off_right = Vector2(base_off_x, base_off_y) * _stack_scale_right
|
|
|
|
var show_l = (i >= left_threshold_idx)
|
|
var show_r = (i >= right_threshold_idx)
|
|
|
|
if force_hide_left: show_l = false
|
|
if force_hide_right: show_r = false
|
|
|
|
if static_left:
|
|
l_node.position = static_left.position + final_off_left
|
|
l_node.visible = show_l
|
|
if static_right:
|
|
r_node.position = static_right.position + final_off_right
|
|
r_node.visible = show_r
|
|
|
|
|
|
func _get_layer_count_for_spread(spread_idx: float, total_layers: int) -> int:
|
|
if total_spreads <= 2: return 0
|
|
var effective_total = float(total_spreads - 2)
|
|
var adjusted_idx = spread_idx - 1.0
|
|
var real_max = max(1.0, effective_total)
|
|
var safe_spread = clamp(adjusted_idx, 0.0, real_max)
|
|
var ratio = safe_spread / real_max
|
|
return int(round(ratio * total_layers))
|
|
|
|
|
|
func _update_volume_visuals():
|
|
if not _volume_root: return
|
|
_volume_root.position = volume_stack_offset
|
|
|
|
|
|
# ==============================================================================
|
|
# EDITOR TOOLS & SIZING
|
|
# ==============================================================================
|
|
func _on_apply_size_pressed(val):
|
|
if not val: return
|
|
apply_size_change = false
|
|
_apply_new_size()
|
|
|
|
|
|
func _apply_new_size():
|
|
print("[BookController] Rebuilding Book with size: ", target_page_size)
|
|
page_width = target_page_size.x
|
|
_update_viewports_recursive(self, target_page_size)
|
|
var w = target_page_size.x
|
|
var h = target_page_size.y
|
|
var poly_shape = PackedVector2Array([Vector2(0, -h/2.0), Vector2(w, -h/2.0), Vector2(w, h/2.0), Vector2(0, h/2.0)])
|
|
var uv_rect = PackedVector2Array([Vector2(0, 0), Vector2(w, 0), Vector2(w, h), Vector2(0, h)])
|
|
|
|
if static_left:
|
|
static_left.polygon = poly_shape; static_left.uv = uv_rect; static_left.position = Vector2(-w, 0); static_left.visible = false
|
|
if static_right:
|
|
static_right.polygon = poly_shape; static_right.uv = uv_rect; static_right.position = Vector2(0, 0); static_right.visible = true
|
|
if dynamic_poly:
|
|
dynamic_poly.position = Vector2(0.0, -h / 2.0); dynamic_poly.visible = false
|
|
if dynamic_poly.has_method("rebuild"): dynamic_poly.rebuild(target_page_size)
|
|
|
|
_build_spine()
|
|
_generate_volume_layers()
|
|
_fit_camera_to_book()
|
|
|
|
|
|
func _update_viewports_recursive(node: Node, new_size: Vector2):
|
|
for child in node.get_children():
|
|
if child is SubViewport:
|
|
child.size = new_size; child.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
if child.get_child_count() > 0: _update_viewports_recursive(child, new_size)
|
|
|
|
|
|
func _fit_camera_to_book():
|
|
if not use_internal_camera: return
|
|
var cam = get_node_or_null("Camera2D")
|
|
if not cam: return
|
|
var total_book_width = target_page_size.x * 2.0
|
|
var total_book_height = target_page_size.y * 2.0
|
|
var margin = 1.0
|
|
var required_width = total_book_width * margin
|
|
var required_height = total_book_height * margin
|
|
var screen_size = get_viewport_rect().size
|
|
if screen_size == Vector2.ZERO: return
|
|
var zoom_x = screen_size.x / required_width
|
|
var zoom_y = screen_size.y / required_height
|
|
var final_zoom = min(zoom_x, zoom_y)
|
|
cam.zoom = Vector2(final_zoom, final_zoom)
|
|
|
|
|
|
func _build_spine():
|
|
if _spine_poly and is_instance_valid(_spine_poly): _spine_poly.queue_free()
|
|
elif visuals_container.has_node("RuntimeSpine"):
|
|
var node = visuals_container.get_node("RuntimeSpine")
|
|
node.queue_free()
|
|
visuals_container.remove_child(node)
|
|
if spine_width <= 0: return
|
|
_spine_poly = Polygon2D.new()
|
|
_spine_poly.name = "RuntimeSpine"
|
|
_spine_poly.z_index = 15
|
|
_spine_poly.texture = spine_texture
|
|
var hw = spine_width / 2.0
|
|
var h = target_page_size.y
|
|
_spine_poly.polygon = PackedVector2Array([Vector2(-hw, -h/2.0), Vector2(hw, -h/2.0), Vector2(hw, h/2.0), Vector2(-hw, h/2.0)])
|
|
if spine_texture:
|
|
var tw = spine_texture.get_width()
|
|
var th = spine_texture.get_height()
|
|
_spine_poly.uv = PackedVector2Array([Vector2(0, 0), Vector2(tw, 0), Vector2(tw, th), Vector2(0, th)])
|
|
else: _spine_poly.color = spine_color
|
|
visuals_container.add_child(_spine_poly)
|
|
_spine_poly.position = Vector2.ZERO
|
|
if Engine.is_editor_hint(): _spine_poly.owner = get_tree().edited_scene_root
|
|
|
|
|
|
func _set_page_visible(node: Node2D, show: bool):
|
|
if node: node.visible = show
|
|
|
|
|
|
func _prepare_book_content():
|
|
_runtime_pages = pages_paths.duplicate()
|
|
if _runtime_pages.size() > 0 and _runtime_pages.size() % 2 != 0:
|
|
_runtime_pages.append("internal://blank_page")
|
|
var num = _runtime_pages.size()
|
|
if num == 0: total_spreads = 1
|
|
else: total_spreads = (num / 2) + 1
|
|
|
|
|
|
# ==============================================================================
|
|
# INPUT
|
|
# ==============================================================================
|
|
func _inject_event_to_viewport(viewport: SubViewport, polygon: Polygon2D, event: InputEvent) -> void:
|
|
var mouse_pos = get_global_mouse_position()
|
|
var new_mouse_pos = polygon.to_local(mouse_pos)
|
|
new_mouse_pos.y += target_page_size.y / 2
|
|
|
|
var ev = event.duplicate_deep(Resource.DEEP_DUPLICATE_ALL)
|
|
ev.position = new_mouse_pos
|
|
ev.global_position = new_mouse_pos
|
|
|
|
var node = viewport.gui_get_hovered_control()
|
|
|
|
if not _active_viewports_state.get(viewport, false):
|
|
viewport.notify_mouse_entered()
|
|
_active_viewports_state[viewport] = true
|
|
|
|
viewport.push_input(ev, true)
|
|
|
|
if node is Control:
|
|
var cursor_shape = node.get_default_cursor_shape()
|
|
DisplayServer.cursor_set_shape.call_deferred(cursor_shape)
|
|
else:
|
|
DisplayServer.cursor_set_shape.call_deferred(DisplayServer.CursorShape.CURSOR_ARROW)
|
|
|
|
|
|
## Clears the viewport input state. Call this when interaction stops.
|
|
func _reset_viewport_input_state() -> void:
|
|
for viewport in _active_viewports_state:
|
|
if is_instance_valid(viewport):
|
|
viewport.notify_mouse_exited()
|
|
|
|
_active_viewports_state.clear()
|
|
|
|
|
|
func _input(event):
|
|
pass
|
|
#if not _active_interactive_is_left and not _active_interactive_is_right:
|
|
#if not _active_viewports_state.is_empty():
|
|
#_reset_viewport_input_state()
|
|
#return
|
|
#
|
|
#if event is InputEventMouse or event is InputEventMouseMotion:
|
|
#if _active_interactive_is_left:
|
|
#_slot_1.physics_object_picking = true
|
|
#_inject_event_to_viewport(_slot_1, static_left, event)
|
|
#elif _active_viewports_state.has(_slot_1):
|
|
#_slot_1.physics_object_picking = false
|
|
#_slot_1.notify_mouse_exited()
|
|
#_active_viewports_state.erase(_slot_1)
|
|
#
|
|
#if _active_interactive_is_right:
|
|
#_slot_2.physics_object_picking = true
|
|
#_inject_event_to_viewport(_slot_2, static_right, event)
|
|
#elif _active_viewports_state.has(_slot_2):
|
|
#_slot_2.physics_object_picking = false
|
|
#_slot_2.notify_mouse_exited()
|
|
#_active_viewports_state.erase(_slot_2)
|
|
#
|
|
#elif event is InputEventKey:
|
|
#if _active_interactive_is_left: _slot_1.push_input(event.duplicate(true))
|
|
|
|
|
|
func _unhandled_input(event):
|
|
if Engine.is_editor_hint(): return
|
|
if not visible or is_animating: return
|
|
|
|
if event.is_action_pressed("ui_cancel") and close_condition == CloseCondition.ON_CANCEL_INPUT:
|
|
_perform_close_action()
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
|
|
if event.is_action_pressed("ui_right"): next_page(); get_viewport().set_input_as_handled()
|
|
elif event.is_action_pressed("ui_left"): prev_page(); get_viewport().set_input_as_handled()
|
|
|
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
|
var local_pos = visuals_container.get_local_mouse_position()
|
|
local_pos.y /= visuals_container.scale.y
|
|
if local_pos.x > -page_width/2.0: next_page(); get_viewport().set_input_as_handled()
|
|
else: prev_page(); get_viewport().set_input_as_handled()
|
|
|
|
|
|
func next_page():
|
|
if is_animating or current_spread >= total_spreads: return
|
|
_start_animation(true)
|
|
|
|
|
|
func prev_page():
|
|
if is_animating or current_spread <= -1: return
|
|
_start_animation(false)
|
|
|
|
|
|
func _pageflip_set_input_enabled(give_control_to_book: bool):
|
|
set_process_unhandled_input(give_control_to_book)
|
|
if _slot_1.get_child_count() > 0:
|
|
var node = _slot_1.get_child(-1)
|
|
if node.has_meta("_pageflip_node") and node.has_signal("manage_pageflip"):
|
|
node.set_process_input(not give_control_to_book)
|
|
node.set_process_unhandled_input(not give_control_to_book)
|
|
_active_interactive_is_left = not give_control_to_book
|
|
if _slot_2.get_child_count() > 0:
|
|
var node = _slot_2.get_child(-1)
|
|
if node.has_meta("_pageflip_node") and node.has_signal("manage_pageflip"):
|
|
node.set_process_input(not give_control_to_book)
|
|
node.set_process_unhandled_input(not give_control_to_book)
|
|
_active_interactive_is_right = not give_control_to_book
|
|
|
|
|
|
## Internal function that handles the actual jump logic using specific spread indices.
|
|
func _go_to_page(target_spread_idx: int) -> void:
|
|
if is_animating: return
|
|
|
|
if target_spread_idx == current_spread:
|
|
return
|
|
|
|
var forward = target_spread_idx > current_spread
|
|
|
|
_pageflip_set_input_enabled(true)
|
|
|
|
_is_jumping = true
|
|
_jump_target_spread = target_spread_idx
|
|
|
|
_start_animation(forward)
|
|
|
|
|
|
## API to jump to a specific page or cover.
|
|
## [param page_num]: The 1-based page number (1 = first texture in pages_paths).
|
|
## [param target]: Specifies if the target is a content page or a cover.
|
|
func go_to_page(page_num: int = 1, target: JumpTarget = JumpTarget.CONTENT_PAGE) -> void:
|
|
if is_animating: return
|
|
|
|
var target_spread_idx: int = 0
|
|
|
|
match target:
|
|
JumpTarget.FRONT_COVER:
|
|
target_spread_idx = -1
|
|
JumpTarget.BACK_COVER:
|
|
target_spread_idx = total_spreads
|
|
JumpTarget.CONTENT_PAGE:
|
|
var safe_page = max(1, page_num)
|
|
target_spread_idx = int(safe_page / 2.0)
|
|
|
|
target_spread_idx = clampi(target_spread_idx, 0, total_spreads - 1)
|
|
|
|
_go_to_page(target_spread_idx)
|
|
|
|
|
|
## start from the current visual state and end at the cover.
|
|
func force_close_book(to_front_cover: bool):
|
|
if is_animating: return
|
|
_pageflip_set_input_enabled(true)
|
|
|
|
_is_force_closing = true
|
|
|
|
if to_front_cover:
|
|
# To Front Cover = Closing Backwards (Right to Left)
|
|
_start_animation(false)
|
|
else:
|
|
# To Back Cover = Closing Forwards (Left to Right)
|
|
_start_animation(true)
|
|
|
|
|
|
# ==============================================================================
|
|
# ANIMATION LOGIC
|
|
# ==============================================================================
|
|
func _set_flying_slots_active(is_active: bool) -> void:
|
|
var mode = SubViewport.UPDATE_ALWAYS if is_active else SubViewport.UPDATE_DISABLED
|
|
|
|
if _slot_3: _slot_3.render_target_update_mode = mode
|
|
if _slot_4: _slot_4.render_target_update_mode = mode
|
|
|
|
|
|
func _start_animation(forward: bool) -> void:
|
|
started_page_flip_animation.emit()
|
|
is_animating = true
|
|
going_forward = forward
|
|
_set_flying_slots_active(true)
|
|
|
|
var is_rigid_motion = false
|
|
var use_tween = false
|
|
var closing_to_back = false
|
|
var closing_to_front = false
|
|
var target_is_closed = false
|
|
|
|
var target_spread_idx = 0
|
|
|
|
if _is_force_closing:
|
|
if forward: # Closing to Back
|
|
target_spread_idx = total_spreads
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = false
|
|
closing_to_back = true
|
|
target_is_closed = true
|
|
else: # Closing to Front
|
|
target_spread_idx = -1
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = false
|
|
closing_to_front = true
|
|
target_is_closed = true
|
|
elif _is_jumping:
|
|
target_spread_idx = _jump_target_spread
|
|
if target_spread_idx == -1:
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = false
|
|
closing_to_front = true
|
|
target_is_closed = true
|
|
elif target_spread_idx == total_spreads:
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = false
|
|
closing_to_back = true
|
|
target_is_closed = true
|
|
else:
|
|
is_rigid_motion = false
|
|
use_tween = false
|
|
is_book_open = true
|
|
target_is_closed = false
|
|
else:
|
|
target_spread_idx = current_spread + 1 if forward else current_spread - 1
|
|
|
|
if forward:
|
|
if current_spread == -1:
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = true
|
|
target_is_closed = false
|
|
elif current_spread == total_spreads - 1:
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = false
|
|
closing_to_back = true
|
|
target_is_closed = true
|
|
else:
|
|
if current_spread == total_spreads:
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = true
|
|
target_is_closed = false
|
|
elif current_spread == 0:
|
|
is_rigid_motion = covers_are_rigid
|
|
use_tween = true
|
|
is_book_open = false
|
|
closing_to_front = true
|
|
target_is_closed = true
|
|
|
|
_pending_target_spread_idx = target_spread_idx
|
|
|
|
_force_hide_vol_left = false
|
|
_force_hide_vol_right = false
|
|
if target_is_closed and is_rigid_motion:
|
|
if forward:
|
|
_force_hide_vol_right = true
|
|
else:
|
|
_force_hide_vol_left = true
|
|
|
|
_is_page_flying = true
|
|
_flying_from_right = forward
|
|
_visual_spread_index = float(current_spread)
|
|
|
|
# --- SLOT PRE-CONFIGURATION ---
|
|
var idx_static_left = -999
|
|
var idx_static_right = -999
|
|
var idx_anim_a = -999
|
|
var idx_anim_b = -999
|
|
|
|
if _is_force_closing:
|
|
if forward:
|
|
idx_static_left = _get_page_index_for_spread(current_spread, true)
|
|
idx_static_right = -999
|
|
idx_anim_a = _get_page_index_for_spread(current_spread, false)
|
|
idx_anim_b = -103
|
|
else:
|
|
idx_static_right = _get_page_index_for_spread(current_spread, false)
|
|
idx_static_left = -999
|
|
idx_anim_a = _get_page_index_for_spread(current_spread, true)
|
|
idx_anim_b = -100
|
|
elif _is_jumping:
|
|
if forward:
|
|
idx_static_left = _get_page_index_for_spread(current_spread, true)
|
|
idx_static_right = _get_page_index_for_spread(target_spread_idx, false)
|
|
idx_anim_a = _get_page_index_for_spread(current_spread, false)
|
|
idx_anim_b = _get_page_index_for_spread(target_spread_idx, true)
|
|
else:
|
|
idx_static_left = _get_page_index_for_spread(target_spread_idx, true)
|
|
idx_static_right = _get_page_index_for_spread(current_spread, false)
|
|
idx_anim_a = _get_page_index_for_spread(current_spread, true)
|
|
idx_anim_b = _get_page_index_for_spread(target_spread_idx, false)
|
|
else:
|
|
if forward:
|
|
idx_static_left = _get_page_index_for_spread(current_spread, true)
|
|
idx_static_right = _get_page_index_for_spread(target_spread_idx, false)
|
|
idx_anim_a = _get_page_index_for_spread(current_spread, false)
|
|
idx_anim_b = _get_page_index_for_spread(target_spread_idx, true)
|
|
else:
|
|
idx_static_left = _get_page_index_for_spread(target_spread_idx, true)
|
|
idx_static_right = _get_page_index_for_spread(current_spread, false)
|
|
idx_anim_a = _get_page_index_for_spread(current_spread, true)
|
|
idx_anim_b = _get_page_index_for_spread(target_spread_idx, false)
|
|
|
|
_update_slot_content(_slot_1, idx_static_left)
|
|
_update_slot_content(_slot_2, idx_static_right)
|
|
_update_slot_content(_slot_3, idx_anim_a)
|
|
_update_slot_content(_slot_4, idx_anim_b)
|
|
|
|
static_left.texture = _slot_1.get_texture()
|
|
static_right.texture = _slot_2.get_texture()
|
|
|
|
var tex_front = _slot_3.get_texture()
|
|
var tex_back = _slot_4.get_texture()
|
|
|
|
dynamic_poly.texture = tex_back
|
|
|
|
if dynamic_poly.material is ShaderMaterial:
|
|
# Reset shader params immediately to avoid visual glitches
|
|
dynamic_poly.material.set_shader_parameter("shadow_intensity", 0.0)
|
|
dynamic_poly.material.set_shader_parameter("max_shadow_spread", 0.0)
|
|
|
|
var shadow_tex = dynamic_poly.get("shadow_gradient")
|
|
if shadow_tex:
|
|
dynamic_poly.material.set_shader_parameter("spine_shadow_gradient", shadow_tex)
|
|
|
|
dynamic_poly.material.set_shader_parameter("is_backward", not forward)
|
|
if forward:
|
|
dynamic_poly.material.set_shader_parameter("front_texture", tex_front)
|
|
dynamic_poly.material.set_shader_parameter("back_texture", tex_back)
|
|
else:
|
|
dynamic_poly.material.set_shader_parameter("front_texture", tex_back)
|
|
dynamic_poly.material.set_shader_parameter("back_texture", tex_front)
|
|
|
|
if closing_to_back:
|
|
_set_page_visible.call_deferred(static_right, false)
|
|
elif closing_to_front:
|
|
_set_page_visible.call_deferred(static_left, false)
|
|
|
|
var base_anim_name = "turn_rigid_page" if is_rigid_motion else "turn_flexible_page"
|
|
var final_anim_name = base_anim_name if forward else base_anim_name + "_mirror"
|
|
|
|
var anim_len = 1.0
|
|
if anim_player.has_animation(final_anim_name):
|
|
anim_len = anim_player.get_animation(final_anim_name).length
|
|
anim_player.current_animation = final_anim_name
|
|
anim_player.seek(0.0, true)
|
|
|
|
_set_page_visible.call_deferred(dynamic_poly, true)
|
|
dynamic_poly.z_index = 10
|
|
_update_stack_direct(_current_expansion_factor, float(current_spread))
|
|
|
|
await RenderingServer.frame_post_draw
|
|
|
|
var actual_speed = flip_speed_scale if flip_speed_scale > 0.0 else 1.0
|
|
anim_player.speed_scale = actual_speed
|
|
var motion_duration = anim_len / actual_speed
|
|
var land_time = max(0.0, motion_duration - landing_overlap)
|
|
get_tree().create_timer(land_time).timeout.connect(_on_page_landed_early)
|
|
|
|
var start_exp = _current_expansion_factor
|
|
var end_exp = 1.0 if target_is_closed else 0.0
|
|
var tween = create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT).set_parallel(true)
|
|
var half_duration = motion_duration * 0.5
|
|
|
|
var overlap_factor = 0.8
|
|
var delay_time = half_duration * overlap_factor
|
|
var entry_duration = motion_duration - delay_time
|
|
|
|
# --- PAGE SHADER SHADOW ANIMATION (TWEEN) ---
|
|
# We create a separate sequential tween for the shadow "pop & fade" effect
|
|
if dynamic_poly.material is ShaderMaterial and not is_rigid_motion:
|
|
var target_spread = dynamic_poly.get("face_shadow_spread")
|
|
if target_spread == null: target_spread = 0.5
|
|
|
|
#var shadow_tween = create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
|
## Phase 1: Expand Shadow (0 -> Peak)
|
|
#shadow_tween.tween_property(dynamic_poly.material, "shader_parameter/shadow_intensity", 0.65, half_duration)
|
|
#shadow_tween.parallel().tween_property(dynamic_poly.material, "shader_parameter/max_shadow_spread", target_spread, half_duration).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
|
## Phase 2: Contract Shadow (Peak -> 0)
|
|
#var d = half_duration * 0.8
|
|
#shadow_tween.set_ease(Tween.EASE_IN)
|
|
#shadow_tween.tween_property(dynamic_poly.material, "shadow_parameter/shadow_intensity", 0.0, d)
|
|
#shadow_tween.parallel().tween_property(dynamic_poly.material, "shader_parameter/max_shadow_spread", 0.0, d).set_trans(Tween.TRANS_SINE)
|
|
dynamic_poly.material.set_shader_parameter("shadow_intensity", 0.0)
|
|
var start_thin_L = (current_spread <= 1)
|
|
var end_thin_L = (target_spread_idx <= 1)
|
|
|
|
if start_thin_L and not end_thin_L:
|
|
_stack_scale_left = 0.0
|
|
tween.tween_property(self, "_stack_scale_left", 1.0, entry_duration).set_delay(delay_time).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
|
elif not start_thin_L and end_thin_L:
|
|
_stack_scale_left = 1.0
|
|
tween.tween_property(self, "_stack_scale_left", 0.0, half_duration * 0.8)
|
|
elif start_thin_L and end_thin_L:
|
|
_stack_scale_left = 0.0
|
|
else:
|
|
_stack_scale_left = 1.0
|
|
|
|
var start_thin_R = (current_spread >= total_spreads - 2)
|
|
var end_thin_R = (target_spread_idx >= total_spreads - 2)
|
|
|
|
if start_thin_R and not end_thin_R:
|
|
_stack_scale_right = 0.0
|
|
tween.tween_property(self, "_stack_scale_right", 1.0, entry_duration).set_delay(delay_time).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
|
elif not start_thin_R and end_thin_R:
|
|
_stack_scale_right = 1.0
|
|
tween.tween_property(self, "_stack_scale_right", 0.0, half_duration * 0.8)
|
|
elif start_thin_R and end_thin_R:
|
|
_stack_scale_right = 0.0
|
|
else:
|
|
_stack_scale_right = 1.0
|
|
|
|
if use_tween:
|
|
var compensation_offset = _get_compensation_offset(target_is_closed, closing_to_back)
|
|
var base_offset = compensation_offset - Vector2(target_page_size.x / 2, 0.0)
|
|
_animate_container_transform(target_is_closed, closing_to_back, motion_duration)
|
|
if use_internal_camera:
|
|
var screen_center = _get_screen_center()
|
|
var final_pos = screen_center + base_offset
|
|
tween.tween_property(visuals_container, "global_position", final_pos, motion_duration)
|
|
else:
|
|
tween.tween_property(visuals_container, "position", base_offset, motion_duration)
|
|
|
|
tween.tween_method(_tween_expansion_only.bind(), start_exp, end_exp, motion_duration)
|
|
|
|
anim_player.play(final_anim_name)
|
|
|
|
if is_rigid_motion:
|
|
var trigger_time = max(0.0, motion_duration - impact_sync_offset)
|
|
get_tree().create_timer(trigger_time).timeout.connect(func(): _play_sound(sfx_book_impact))
|
|
else:
|
|
_play_sound(sfx_page_flip)
|
|
|
|
|
|
func _on_page_landed_early():
|
|
_is_page_flying = false
|
|
_visual_spread_index = float(_pending_target_spread_idx)
|
|
_update_stack_direct(_current_expansion_factor, _visual_spread_index)
|
|
|
|
|
|
# ==============================================================================
|
|
# PROCESS
|
|
# ==============================================================================
|
|
func _process(_delta):
|
|
if visuals_container and is_animating:
|
|
_update_stack_direct(_current_expansion_factor, _visual_spread_index)
|
|
_update_volume_visuals()
|
|
|
|
|
|
func _get_screen_center() -> Vector2:
|
|
if use_internal_camera:
|
|
var cam = get_viewport().get_camera_2d()
|
|
if cam: return cam.get_screen_center_position()
|
|
|
|
# If no camera or not using internal, center to viewport rect
|
|
return get_viewport_rect().size / 2.0
|
|
|
|
func _get_compensation_offset(is_closed: bool, is_back: bool) -> Vector2:
|
|
var target_local_x = 0.0
|
|
if is_closed:
|
|
if is_back: target_local_x = -page_width
|
|
else: target_local_x = 0.0
|
|
else: target_local_x = -page_width * 0.5
|
|
var target_vec = Vector2(target_local_x, 0)
|
|
var t_scale = closed_scale if is_closed else open_scale
|
|
var t_rot = closed_rotation if is_closed else open_rotation
|
|
if is_closed and is_back: t_rot *= -1.0
|
|
target_vec *= t_scale
|
|
target_vec = target_vec.rotated(deg_to_rad(t_rot))
|
|
return -target_vec
|
|
|
|
|
|
func _animate_container_transform(target_is_closed: bool, is_back: bool, duration: float):
|
|
if not visuals_container: return
|
|
var t_scale = closed_scale if target_is_closed else open_scale
|
|
var t_skew = closed_skew if target_is_closed else open_skew
|
|
var t_rot = closed_rotation if target_is_closed else open_rotation
|
|
if target_is_closed and is_back: t_skew *= -1.0; t_rot *= -1.0
|
|
if duration <= 0.0:
|
|
visuals_container.scale = t_scale; visuals_container.skew = t_skew; visuals_container.rotation = deg_to_rad(t_rot)
|
|
else:
|
|
var tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
|
|
tween.tween_property(visuals_container, "scale", t_scale, duration)
|
|
tween.tween_property(visuals_container, "skew", t_skew, duration)
|
|
tween.tween_property(visuals_container, "rotation", deg_to_rad(t_rot), duration)
|
|
|
|
|
|
func _play_sound(stream: AudioStream):
|
|
if not audio_player or not stream: return
|
|
audio_player.stream = stream; audio_player.pitch_scale = randf_range(0.95, 1.05); audio_player.play()
|
|
|
|
|
|
# ==============================================================================
|
|
# CORE SIGNALS
|
|
# ==============================================================================
|
|
func _on_midpoint_signal():
|
|
# Shader handles visual flipping now.
|
|
pass
|
|
|
|
|
|
func _on_animation_finished(_anim_name: String):
|
|
_set_page_visible(dynamic_poly, false)
|
|
_set_flying_slots_active(false)
|
|
dynamic_poly.z_index = 10
|
|
|
|
_force_hide_vol_left = false; _force_hide_vol_right = false
|
|
_is_page_flying = false
|
|
|
|
if _is_force_closing:
|
|
_is_force_closing = false
|
|
if going_forward: current_spread = total_spreads
|
|
else: current_spread = -1
|
|
elif _is_jumping:
|
|
_is_jumping = false
|
|
current_spread = _jump_target_spread
|
|
else:
|
|
if going_forward and current_spread == -1: current_spread = 0
|
|
elif going_forward and current_spread == total_spreads - 1: current_spread = total_spreads
|
|
elif !going_forward and current_spread == total_spreads: current_spread = total_spreads - 1
|
|
elif !going_forward and current_spread == 0: current_spread = -1
|
|
else:
|
|
if going_forward: current_spread += 1
|
|
else: current_spread -= 1
|
|
|
|
_update_static_visuals_immediate()
|
|
is_animating = false
|
|
_visual_spread_index = float(current_spread)
|
|
_update_stack_direct(_current_expansion_factor, _visual_spread_index)
|
|
_update_volume_visuals()
|
|
|
|
if current_spread == total_spreads:
|
|
if close_condition == CloseCondition.CLOSE_FROM_BACK or close_condition == CloseCondition.ANY_CLOSE:
|
|
_perform_close_action()
|
|
return
|
|
if current_spread == -1:
|
|
if close_condition == CloseCondition.CLOSE_FROM_FRONT or close_condition == CloseCondition.ANY_CLOSE:
|
|
_perform_close_action()
|
|
return
|
|
_check_scene_activation.call_deferred()
|
|
ended_page_flip_animation.emit()
|
|
|
|
|
|
func _perform_close_action():
|
|
if close_behavior == CloseBehavior.DESTROY_BOOK: queue_free()
|
|
elif close_behavior == CloseBehavior.CHANGE_SCENE:
|
|
if target_scene_on_close != "": get_tree().change_scene_to_file(target_scene_on_close)
|
|
|
|
|
|
func _check_scene_activation() -> void:
|
|
var scene_found = false
|
|
if _slot_1.get_child_count() > 0:
|
|
var node = _slot_1.get_child(-1)
|
|
if node.has_meta("_pageflip_node") and node.has_signal("manage_pageflip"):
|
|
node.emit_signal("manage_pageflip", false)
|
|
node.set_process_input(true); node.set_process_unhandled_input(true); scene_found = true
|
|
if _slot_2.get_child_count() > 0:
|
|
var node = _slot_2.get_child(-1)
|
|
if node.has_meta("_pageflip_node") and node.has_signal("manage_pageflip"):
|
|
node.emit_signal("manage_pageflip", false)
|
|
node.set_process_input(true); node.set_process_unhandled_input(true); scene_found = true
|
|
if not scene_found: _pageflip_set_input_enabled(true)
|
|
|
|
|
|
# ==============================================================================
|
|
# ASSET LOADING & SLOT MANAGEMENT
|
|
# ==============================================================================
|
|
func _get_page_index_for_spread(spread_idx: int, is_left: bool) -> int:
|
|
if spread_idx == -1: return -999 if is_left else -100 # Front Cover Out
|
|
if spread_idx == total_spreads: return -103 if is_left else -999 # Back Cover Out
|
|
if spread_idx == 0:
|
|
if is_left: return -101 # Front Cover In
|
|
if _runtime_pages.size() == 0: return -102 # Back Cover In
|
|
return 0 # Page 1
|
|
if is_left: return (spread_idx * 2) - 1
|
|
else:
|
|
var content_idx = spread_idx * 2
|
|
if content_idx >= _runtime_pages.size(): return -102 # Back Cover In
|
|
return content_idx
|
|
|
|
|
|
func _update_slot_content(slot: SubViewport, content_index: int) -> void:
|
|
if not slot: return
|
|
for child in slot.get_children():
|
|
if child is TextureRect: child.queue_free()
|
|
else: slot.remove_child(child)
|
|
var resource_path = ""
|
|
var cover_tex: Texture2D = null
|
|
if content_index == -100: cover_tex = tex_cover_front_out
|
|
elif content_index == -101: cover_tex = tex_cover_front_in
|
|
elif content_index == -102: cover_tex = tex_cover_back_in
|
|
elif content_index == -103: cover_tex = tex_cover_back_out
|
|
elif content_index >= 0 and content_index < _runtime_pages.size(): resource_path = _runtime_pages[content_index]
|
|
|
|
if cover_tex: _setup_texture_in_slot(slot, cover_tex)
|
|
elif resource_path != "":
|
|
if resource_path == "internal://blank_page": _setup_texture_in_slot(slot, blank_page_texture)
|
|
elif ResourceLoader.exists(resource_path):
|
|
var res = load(resource_path)
|
|
if res is PackedScene: _setup_scene_in_slot(slot, res, content_index)
|
|
elif res is Texture2D: _setup_texture_in_slot(slot, res)
|
|
else: _setup_texture_in_slot(slot, blank_page_texture)
|
|
else: _setup_texture_in_slot(slot, blank_page_texture)
|
|
|
|
|
|
func _setup_texture_in_slot(slot: SubViewport, tex: Texture2D):
|
|
if enable_composite_pages: _add_composite_blank_bg(slot)
|
|
if not tex:
|
|
tex = blank_page_texture
|
|
if not tex: return
|
|
var rect = TextureRect.new()
|
|
rect.texture = tex
|
|
rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
rect.stretch_mode = page_stretch_mode as TextureRect.StretchMode
|
|
rect.size = slot.size
|
|
rect.position = Vector2.ZERO
|
|
slot.add_child(rect)
|
|
|
|
|
|
func _setup_scene_in_slot(slot: SubViewport, scene_pkg: PackedScene, texture_index: int):
|
|
if enable_composite_pages: _add_composite_blank_bg(slot)
|
|
var cache_key = scene_pkg.get_path() + "#" + str(texture_index)
|
|
var instance
|
|
if not cache_key in _scene_cache:
|
|
instance = scene_pkg.instantiate()
|
|
instance.set_meta("_pageflip_node", self)
|
|
instance.position = Vector2.ZERO
|
|
if instance.has_signal("manage_pageflip"): instance.connect("manage_pageflip", _pageflip_set_input_enabled)
|
|
slot.add_child(instance)
|
|
_scene_cache[cache_key] = instance
|
|
else:
|
|
instance = _scene_cache[cache_key]
|
|
if instance.is_inside_tree(): instance.reparent(slot)
|
|
else: slot.add_child(instance)
|
|
instance.set_process_input(false); instance.set_process_unhandled_input(false)
|
|
|
|
|
|
func _add_composite_blank_bg(slot: SubViewport):
|
|
if not blank_page_texture: return
|
|
var bg = TextureRect.new()
|
|
bg.texture = blank_page_texture
|
|
bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
bg.stretch_mode = page_stretch_mode as TextureRect.StretchMode
|
|
bg.size = slot.size
|
|
bg.position = Vector2.ZERO
|
|
slot.add_child(bg)
|
|
|
|
|
|
func _update_static_visuals_immediate():
|
|
var idx_l = _get_page_index_for_spread(current_spread, true)
|
|
var idx_r = _get_page_index_for_spread(current_spread, false)
|
|
_update_slot_content(_slot_1, idx_l)
|
|
_update_slot_content(_slot_2, idx_r)
|
|
static_left.texture = _slot_1.get_texture()
|
|
static_right.texture = _slot_2.get_texture()
|
|
var valid_l = (idx_l != -999)
|
|
var valid_r = (idx_r != -999)
|
|
_set_page_visible(static_left, valid_l)
|
|
_set_page_visible(static_right, valid_r)
|