exchanger-themes/tools/preview/InteractivePetPreview.gd
Danil Iarosh ca52efbedc Initial commit: Exchanger Theme DLC Builder
Godot 4.7 project for authoring, previewing, validating, and building Exchanger PCK themes. Includes the example and space-pixel themes, binding contracts, editor tooling, validation/build scripts, and project documentation.
2026-09-22 11:43:13 +03:00

505 lines
19 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Control
const ACTIVE_STATE_PATH := "res://build/active-theme.json"
const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc"
const PET_MANIFEST_FIELD := "InteractivePetScene"
const PET_COMPONENT_RELATIVE_PATH := "components/interactive_pet.tscn"
const LEGACY_PET_COMPONENT_RELATIVE_PATH := "components/alien_pet.tscn"
const GALLERY_SCENE_PATH := "res://tools/preview/preview.tscn"
const REQUIRED_PET_METHODS := [
&"get_pet_animation_names",
&"get_pet_animation_frame_count",
&"has_pet_animation",
&"play_pet_animation",
&"pause_pet_animation",
&"stop_pet_animation",
&"get_current_pet_animation",
&"get_current_pet_frame",
]
const COMPOSITE_METHODS := [
&"has_pet_composite_action",
&"start_pet_composite_action",
&"cancel_pet_composite_action",
]
const COMPOSITE_SIGNALS := [
&"pet_composite_action_started",
&"pet_composite_action_finished",
]
const COMPOSITE_PREVIEW_ACTION := &"interact"
const COMPOSITE_PREVIEW_VIEWPORT := Rect2(0.0, 0.0, 720.0, 720.0)
const COMPOSITE_PREVIEW_SUPPORT_ID := "preview_highest_support"
const COMPOSITE_PREVIEW_SUPPORT := Vector2(360.0, 128.0)
const COMPOSITE_PREVIEW_START := Vector2(360.0, 620.0)
@onready var stage: Control = %Stage
@onready var animation_name_label: Label = %AnimationName
@onready var frame_label: Label = %FrameCounter
@onready var pause_button: Button = %PauseButton
@onready var auto_button: Button = %AutoButton
@onready var composite_button: Button = %CompositeButton
@onready var animation_buttons: GridContainer = %AnimationButtons
@onready var animation_button_template: Button = %AnimationButtonTemplate
@onready var auto_timer: Timer = %AutoTimer
var pet: Node2D
var _animation_names: Array[StringName] = []
var _current_index := 0
var _paused := false
var _composite_runtime: PetCompositePreviewRuntime
var _composite_run_id := 0
var _composite_display_scale := 1.0
var _composite_display_origin := Vector2.ZERO
func _ready() -> void:
_connect_controls()
if not _instantiate_active_pet():
return
_animation_names = _read_animation_names()
if _animation_names.is_empty():
_show_error("Компонент питомца не содержит доступных анимаций.")
return
_build_animation_buttons()
_center_pet()
_configure_composite_preview()
var initial_name := StringName(str(pet.call(&"get_current_pet_animation")))
_current_index = maxi(0, _animation_names.find(initial_name))
_play_current_animation()
func _process(_delta: float) -> void:
if not is_instance_valid(pet):
set_process(false)
return
if _animation_names.is_empty():
frame_label.text = "КАДР: —"
return
var animation_name := StringName(str(pet.call(&"get_current_pet_animation")))
var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name))
var current_frame := int(pet.call(&"get_current_pet_frame"))
frame_label.text = "КАДР: %d / %d" % [current_frame + 1, frame_count]
func _unhandled_key_input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
match event.keycode:
KEY_LEFT:
_select_relative(-1)
KEY_RIGHT:
_select_relative(1)
KEY_SPACE:
_toggle_pause()
KEY_R:
_play_current_animation()
KEY_ESCAPE:
_return_to_gallery()
return
_:
return
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
func _connect_controls() -> void:
stage.resized.connect(_center_pet)
animation_buttons.resized.connect(_resize_animation_buttons)
%GalleryButton.pressed.connect(_return_to_gallery)
%PreviousButton.pressed.connect(_select_relative.bind(-1))
%NextButton.pressed.connect(_select_relative.bind(1))
pause_button.pressed.connect(_toggle_pause)
%ReplayButton.pressed.connect(_play_current_animation)
auto_button.toggled.connect(_on_auto_toggled)
composite_button.pressed.connect(_start_composite_preview)
auto_timer.timeout.connect(_select_relative.bind(1))
func _instantiate_active_pet() -> bool:
var source_package := _active_package_path()
var component_path := _resolve_pet_component_path(source_package)
if component_path.is_empty():
_show_error("В активной теме не задан компонент интерактивного питомца.")
return false
var packed_scene := load(component_path) as PackedScene
if packed_scene == null:
_show_error("Не удалось загрузить компонент питомца: %s" % component_path)
return false
var instance := packed_scene.instantiate()
if not instance is Node2D:
instance.queue_free()
_show_error("Корень компонента питомца должен наследовать Node2D.")
return false
pet = instance as Node2D
for method_name in REQUIRED_PET_METHODS:
if not pet.has_method(method_name):
pet.queue_free()
pet = null
_show_error("У компонента питомца отсутствует метод %s()." % method_name)
return false
if not pet.has_signal(&"pet_animation_completed"):
pet.queue_free()
pet = null
_show_error("У компонента питомца отсутствует сигнал pet_animation_completed.")
return false
stage.add_child(pet)
pet.connect(&"pet_animation_completed", _on_pet_animation_completed)
if pet.has_signal(&"pet_composite_action_finished"):
pet.connect(&"pet_composite_action_finished", _on_composite_action_finished)
return true
func _read_animation_names() -> Array[StringName]:
var result: Array[StringName] = []
var raw_names = pet.call(&"get_pet_animation_names")
if not raw_names is Array:
return result
for raw_name in raw_names:
var animation_name := StringName(str(raw_name))
if not animation_name.is_empty() and not result.has(animation_name):
result.append(animation_name)
return result
func _build_animation_buttons() -> void:
var group := ButtonGroup.new()
for animation_name in _animation_names:
var button := animation_button_template.duplicate() as Button
button.name = "Animation_%s" % _safe_node_name(animation_name)
button.visible = true
button.text = str(animation_name)
button.button_group = group
button.set_meta(&"animation_name", animation_name)
button.pressed.connect(_select_animation.bind(animation_name))
animation_buttons.add_child(button)
animation_button_template.visible = false
_resize_animation_buttons()
func _resize_animation_buttons() -> void:
var separation := float(animation_buttons.get_theme_constant("h_separation"))
var column_width := floorf((animation_buttons.size.x - separation) * 0.5)
if column_width <= 0.0:
return
for child in animation_buttons.get_children():
if child is Button:
child.custom_minimum_size.x = column_width
func _center_pet() -> void:
if not is_instance_valid(pet):
return
if _is_composite_preview_active():
_layout_composite_preview()
return
var frame_size := Vector2(128.0, 128.0)
if pet.has_method(&"get_pet_preview_size"):
var reported_size = pet.call(&"get_pet_preview_size")
if reported_size is Vector2 and reported_size.x > 0.0 and reported_size.y > 0.0:
frame_size = reported_size
var min_visual_offset := Vector2.ZERO
var max_visual_offset := Vector2.ZERO
if pet.has_method(&"get_pet_animation_visual_offset"):
for animation_name in _animation_names:
var raw_offset = pet.call(&"get_pet_animation_visual_offset", animation_name)
if raw_offset is Vector2:
min_visual_offset.x = minf(min_visual_offset.x, raw_offset.x)
min_visual_offset.y = minf(min_visual_offset.y, raw_offset.y)
max_visual_offset.x = maxf(max_visual_offset.x, raw_offset.x)
max_visual_offset.y = maxf(max_visual_offset.y, raw_offset.y)
var preview_envelope_size := frame_size + max_visual_offset - min_visual_offset
var available_size := stage.size
if available_size.x <= 0.0 or available_size.y <= 0.0:
return
var preview_scale := minf(4.0, minf(
available_size.x / preview_envelope_size.x,
available_size.y / preview_envelope_size.y
))
preview_scale = maxf(preview_scale, 0.01)
pet.scale = Vector2.ONE * preview_scale
pet.position = (
(available_size - preview_envelope_size * preview_scale) * 0.5
- min_visual_offset * preview_scale
)
func _select_relative(offset: int) -> void:
if _animation_names.is_empty():
return
_current_index = wrapi(_current_index + offset, 0, _animation_names.size())
_play_current_animation()
func _select_animation(animation_name: StringName) -> void:
var index := _animation_names.find(animation_name)
if index < 0:
return
_current_index = index
_play_current_animation()
func _play_current_animation() -> void:
if _animation_names.is_empty() or not is_instance_valid(pet):
return
_cancel_composite_preview(&"manual_animation")
var animation_name := _animation_names[_current_index]
if not bool(pet.call(&"play_pet_animation", animation_name)):
_show_error("Компонент отклонил анимацию %s." % animation_name)
return
_paused = false
pause_button.text = "ПАУЗА"
_update_animation_info(animation_name)
_update_animation_buttons(animation_name)
_schedule_auto_advance(animation_name)
func _toggle_pause() -> void:
if _animation_names.is_empty() or not is_instance_valid(pet):
return
_paused = not _paused
if _paused:
pet.call(&"pause_pet_animation")
auto_timer.paused = true
pause_button.text = "ПРОДОЛЖИТЬ"
else:
pet.call(&"play_pet_animation", _animation_names[_current_index], false)
auto_timer.paused = false
pause_button.text = "ПАУЗА"
func _on_auto_toggled(enabled: bool) -> void:
auto_button.text = "АВТОПОКАЗ: ВКЛ" if enabled else "АВТОПОКАЗ: ВЫКЛ"
if enabled:
_play_current_animation()
else:
auto_timer.stop()
func _configure_composite_preview() -> void:
var supported := true
for method_name in COMPOSITE_METHODS:
supported = supported and pet.has_method(method_name)
for signal_name in COMPOSITE_SIGNALS:
supported = supported and pet.has_signal(signal_name)
supported = supported and bool(
pet.call(&"has_pet_composite_action", COMPOSITE_PREVIEW_ACTION)
) if supported else false
composite_button.disabled = not supported
composite_button.tooltip_text = (
"Запустить составное действие interact через тестовый runtime host."
if supported
else "Активная тема не предоставляет составное действие interact."
)
func _start_composite_preview() -> void:
if composite_button.disabled or not is_instance_valid(pet):
return
_cancel_composite_preview(&"restarted")
auto_button.set_pressed_no_signal(false)
_on_auto_toggled(false)
_composite_run_id += 1
_composite_runtime = PetCompositePreviewRuntime.new(
_composite_run_id,
COMPOSITE_PREVIEW_VIEWPORT,
COMPOSITE_PREVIEW_START,
COMPOSITE_PREVIEW_SUPPORT_ID,
COMPOSITE_PREVIEW_SUPPORT
)
_composite_runtime.anchor_changed.connect(_apply_composite_anchor)
_layout_composite_preview()
_apply_composite_anchor(COMPOSITE_PREVIEW_START)
var returned_run_id := int(pet.call(
&"start_pet_composite_action",
COMPOSITE_PREVIEW_ACTION,
_composite_runtime,
{"action_repetitions": 3, "reduced_effects": false}
))
if returned_run_id != _composite_run_id:
_cancel_composite_preview(&"start_rejected")
_show_error("Тема отклонила запуск составного действия interact.")
return
composite_button.disabled = true
animation_name_label.text = "СОСТАВНОЕ ДЕЙСТВИЕ: interact"
func _layout_composite_preview() -> void:
var available_size := stage.size
if available_size.x <= 0.0 or available_size.y <= 0.0:
return
_composite_display_scale = minf(
available_size.x / COMPOSITE_PREVIEW_VIEWPORT.size.x,
available_size.y / COMPOSITE_PREVIEW_VIEWPORT.size.y
)
_composite_display_origin = (
available_size - COMPOSITE_PREVIEW_VIEWPORT.size * _composite_display_scale
) * 0.5
if _composite_runtime != null:
_apply_composite_anchor(_composite_runtime.GetAnchor())
func _apply_composite_anchor(anchor: Vector2) -> void:
if not is_instance_valid(pet):
return
var runtime_scale := 1.0
var ground_offset := Vector2.ZERO
if pet.has_method(&"get_pet_runtime_scale"):
runtime_scale = float(pet.call(&"get_pet_runtime_scale"))
if pet.has_method(&"get_pet_ground_offset"):
var reported_offset: Variant = pet.call(&"get_pet_ground_offset")
if reported_offset is Vector2:
ground_offset = reported_offset
pet.scale = Vector2.ONE * runtime_scale * _composite_display_scale
pet.position = _composite_display_origin + (
anchor - ground_offset * runtime_scale
) * _composite_display_scale
func _on_composite_action_finished(
action_name: StringName,
run_id: int,
completed: bool
) -> void:
if run_id != _composite_run_id or action_name != COMPOSITE_PREVIEW_ACTION:
return
if _composite_runtime != null:
_composite_runtime.invalidate()
_composite_runtime = null
composite_button.disabled = false
animation_name_label.text = (
"СОСТАВНОЕ ДЕЙСТВИЕ ЗАВЕРШЕНО"
if completed
else "СОСТАВНОЕ ДЕЙСТВИЕ ОТМЕНЕНО"
)
func _cancel_composite_preview(reason: StringName) -> void:
if not _is_composite_preview_active():
return
pet.call(&"cancel_pet_composite_action", _composite_run_id, reason)
_composite_runtime.invalidate()
_composite_runtime = null
composite_button.disabled = false
func _is_composite_preview_active() -> bool:
return _composite_runtime != null and _composite_runtime.IsActive()
func _on_pet_animation_completed(animation_name: StringName) -> void:
if (
auto_button.button_pressed
and not _animation_names.is_empty()
and animation_name == _animation_names[_current_index]
):
_select_relative(1)
func _schedule_auto_advance(animation_name: StringName) -> void:
auto_timer.stop()
if not auto_button.button_pressed or not is_instance_valid(pet):
return
var is_looping := false
if pet.has_method(&"is_pet_animation_looping"):
is_looping = bool(pet.call(&"is_pet_animation_looping", animation_name))
if not is_looping:
return
var frames_per_second := 0.0
if pet.has_method(&"get_pet_animation_frames_per_second"):
frames_per_second = float(
pet.call(&"get_pet_animation_frames_per_second", animation_name)
)
var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name))
var duration := float(frame_count) / maxf(frames_per_second, 0.01)
auto_timer.start(maxf(duration, 1.0))
func _update_animation_info(animation_name: StringName) -> void:
var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name))
var mode := "РЕЖИМ НЕ УКАЗАН"
if pet.has_method(&"is_pet_animation_looping"):
mode = "ЦИКЛ" if bool(
pet.call(&"is_pet_animation_looping", animation_name)
) else "ОДИН РАЗ"
animation_name_label.text = "АНИМАЦИЯ: %s • %d КАДРОВ • %s" % [
animation_name,
frame_count,
mode,
]
func _update_animation_buttons(animation_name: StringName) -> void:
for child in animation_buttons.get_children():
if child is Button and child.has_meta(&"animation_name"):
(child as Button).set_pressed_no_signal(
StringName(child.get_meta(&"animation_name")) == animation_name
)
func _active_package_path() -> String:
var state := _load_json(ACTIVE_STATE_PATH)
return str(state.get("Package", "")).replace("\\", "/")
func _resolve_pet_component_path(package_path: String) -> String:
if package_path.is_empty():
return ""
var package_res_root := ProjectSettings.localize_path(package_path).replace("\\", "/")
if not package_res_root.begins_with("res://"):
return ""
var manifest := _load_json(package_path.path_join("manifest.json"))
var declared_path := str(manifest.get(PET_MANIFEST_FIELD, ""))
var candidates := PackedStringArray()
if not declared_path.is_empty():
var declared_relative := _package_relative_path(declared_path)
if not declared_relative.is_empty():
candidates.append(declared_relative)
candidates.append(PET_COMPONENT_RELATIVE_PATH)
candidates.append(LEGACY_PET_COMPONENT_RELATIVE_PATH)
for relative_path in candidates:
var candidate := package_res_root.path_join(relative_path)
if ResourceLoader.exists(candidate):
return candidate
return ""
func _package_relative_path(resource_path: String) -> String:
var normalized := resource_path.replace("\\", "/")
if normalized.contains(".."):
return ""
if normalized.begins_with(CANONICAL_PACKAGE_ROOT + "/"):
return normalized.trim_prefix(CANONICAL_PACKAGE_ROOT + "/")
if normalized.begins_with("res://"):
return ""
return normalized.trim_prefix("/")
func _load_json(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var value = JSON.parse_string(FileAccess.get_file_as_string(path))
return value if value is Dictionary else {}
func _safe_node_name(animation_name: StringName) -> String:
var result := str(animation_name)
for character in ["/", "\\", ":", "@", "\"", "%"]:
result = result.replace(character, "_")
return result
func _show_error(message: String) -> void:
push_error("[Interactive Pet Preview] %s" % message)
animation_name_label.text = "ОШИБКА: %s" % message
frame_label.text = "КАДР: —"
pause_button.disabled = true
auto_button.disabled = true
composite_button.disabled = true
set_process(false)
func _return_to_gallery() -> void:
_cancel_composite_preview(&"leave_preview")
set_process(false)
get_tree().change_scene_to_file(GALLERY_SCENE_PATH)