OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / pose_test.gd

315 lines · gd ref:
@tool
## Pose Library Test Scene — connects bridge, exports skeleton for posing,
## and handles switching between edit mode (bridge drives skeleton) and
## preview mode (ProceduralAnimator drives skeleton for locomotion).
##
## Usage:
##   1. Open this scene in Godot editor (bridge auto-starts)
##   2. In Blender, connect + create poses in Asset Browser
##   3. Select a pose in Blender's Godot panel → edit it live
##   4. Click "Start Preview" → character walks with WASD using the poses
extends Node3D

const ProceduralAnimatorScript := preload("res://scenes/player/procedural/procedural_animator.gd")
const PoseLibraryClass := preload("res://scenes/player/procedural/pose_library.gd")

@export var auto_start: bool = true
@export var socket_path: String = "/tmp/blender_bridge.sock"

@export_group("Pose Library Status")
@export var active_pose: String = "(none)"
@export var pose_count: int = 0
@export var pose_names_list: String = "(empty)"
@export var preview_active: bool = false
@export var preview_speed: float = 3.0
@export var save_library_now: bool = false: set = _on_save_library

@export_group("Procedural Animation Tuning")
@export_range(0.0, 1.0, 0.01) var hip_drop_max: float = 0.45
@export_range(0.0, 30.0, 0.5) var transition_speed_idle: float = 6.0
@export_range(0.0, 30.0, 0.5) var transition_speed_walk: float = 8.0
@export_range(0.0, 30.0, 0.5) var transition_speed_jump: float = 10.0
@export_range(0.0, 30.0, 0.5) var transition_speed_land: float = 12.0
@export_range(0.0, 30.0, 0.5) var transition_speed_fall: float = 5.0

@export_group("Arm Physics")
@export_range(0.5, 1.0, 0.01) var arm_drag: float = 0.85
@export_range(0.0, 1.0, 0.01) var arm_stiffness_loose: float = 0.85
@export_range(0.0, 1.0, 0.01) var arm_stiffness_stiff: float = 0.7

@export_group("Wall Climbing")
@export_range(0.5, 5.0, 0.1) var climb_speed_up: float = 2.0
@export_range(0.5, 5.0, 0.1) var climb_speed_lateral: float = 3.0
@export_range(0.05, 0.5, 0.01) var climb_step_threshold: float = 0.25
@export_range(0.05, 0.3, 0.01) var climb_arc_height: float = 0.15

@export_group("Pose Preview Buttons")
@export var show_idle: bool = false: set = _show_idle
@export var show_walk_a: bool = false: set = _show_walk_a
@export var show_walk_b: bool = false: set = _show_walk_b
@export var show_run_a: bool = false: set = _show_run_a
@export var show_run_b: bool = false: set = _show_run_b
@export var show_jump_launch: bool = false: set = _show_jump_launch
@export var show_jump_air: bool = false: set = _show_jump_air
@export var show_fall: bool = false: set = _show_fall
@export var show_land: bool = false: set = _show_land
@export var show_roll_start: bool = false: set = _show_roll_start
@export var capture_current: bool = false: set = _on_capture_current

func _show_idle(v: bool) -> void: _show_pose(v, "idle", "show_idle")
func _show_walk_a(v: bool) -> void: _show_pose(v, "walk_a", "show_walk_a")
func _show_walk_b(v: bool) -> void: _show_pose(v, "walk_b", "show_walk_b")
func _show_run_a(v: bool) -> void: _show_pose(v, "run_a", "show_run_a")
func _show_run_b(v: bool) -> void: _show_pose(v, "run_b", "show_run_b")
func _show_jump_launch(v: bool) -> void: _show_pose(v, "jump_launch", "show_jump_launch")
func _show_jump_air(v: bool) -> void: _show_pose(v, "jump_air", "show_jump_air")
func _show_fall(v: bool) -> void: _show_pose(v, "fall", "show_fall")
func _show_land(v: bool) -> void: _show_pose(v, "land", "show_land")
func _show_roll_start(v: bool) -> void: _show_pose(v, "roll_start", "show_roll_start")

func _show_pose(value: bool, pose_name: String, prop_name: String) -> void:
	if not value or not Engine.is_editor_hint():
		return
	set(prop_name, false)
	_apply_pose_to_skeleton(pose_name)

func _on_save_library(value: bool) -> void:
	if not value or not Engine.is_editor_hint():
		return
	save_library_now = false
	if _pose_lib:
		ResourceSaver.save(_pose_lib, POSE_LIB_PATH)
		print("[PoseTest] PoseLibrary manually saved to ", POSE_LIB_PATH)

func _apply_pose_to_skeleton(pose_name: String) -> void:
	if _pose_lib == null or _skeleton == null:
		print("[PoseTest] No pose lib or skeleton")
		return
	var pose: Dictionary = _pose_lib.get_pose(pose_name)
	if pose.is_empty():
		print("[PoseTest] Pose '", pose_name, "' is empty (no bone data)")
		return
	for bone_name: String in pose:
		var idx: int = _skeleton.find_bone(bone_name)
		if idx >= 0:
			var xform: Transform3D = pose[bone_name]
			_skeleton.set_bone_pose_position(idx, xform.origin)
			_skeleton.set_bone_pose_rotation(idx, xform.basis.get_rotation_quaternion())
			_skeleton.set_bone_pose_scale(idx, xform.basis.get_scale())
	print("[PoseTest] Applied pose '", pose_name, "' to skeleton (", pose.size(), " bones)")

func _on_capture_current(value: bool) -> void:
	if not value or not Engine.is_editor_hint():
		return
	capture_current = false
	if _skeleton == null or _pose_lib == null or active_pose == "(none)":
		print("[PoseTest] Cannot capture: no skeleton, lib, or active pose")
		return
	var pose: Dictionary = {}
	for i in range(_skeleton.get_bone_count()):
		var bone_name: String = _skeleton.get_bone_name(i)
		var pos: Vector3 = _skeleton.get_bone_pose_position(i)
		var rot: Quaternion = _skeleton.get_bone_pose_rotation(i)
		var scl: Vector3 = _skeleton.get_bone_pose_scale(i)
		pose[bone_name] = Transform3D(Basis(rot).scaled(scl), pos)
	_on_pose_captured(active_pose, pose)
	print("[PoseTest] Manually captured current skeleton as '", active_pose, "'")

var _bridge: BlenderBridge
var _started: bool = false
var _preview_active: bool = false

func _sync_tuning_to_animator() -> void:
	if _proc_animator == null:
		return
	_proc_animator.hip_drop_max = hip_drop_max
	_proc_animator.fade_speed_idle = transition_speed_idle
	_proc_animator.fade_speed_walk = transition_speed_walk
	_proc_animator.fade_speed_jump = transition_speed_jump
	_proc_animator.fade_speed_land = transition_speed_land
	_proc_animator.fade_speed_fall = transition_speed_fall
var _proc_animator: Node = null
var _pose_lib: Resource = null
var _skeleton: Skeleton3D = null

const POSE_LIB_PATH: String = "res://scenes/player/procedural/tumble_poses.tres"


func _ready() -> void:
	if Engine.is_editor_hint():
		return
	# (The creature spawner that stood here retired with the creature system, #114 phase
	# 5: creatures are bots, placed by a room's own spawner object.)
	# The player in the "player" group: what bot tiers measure distance from.
	var player: Node = $PlayerProcedural
	if player and not player.is_in_group("player"):
		player.add_to_group("player")


func _enter_tree() -> void:
	if not Engine.is_editor_hint():
		return
	call_deferred("_deferred_start")


func _deferred_start() -> void:
	if not auto_start or _started:
		return
	if not is_inside_tree():
		return

	_bridge = $BlenderBridge as BlenderBridge
	if _bridge == null:
		push_warning("[PoseTest] BlenderBridge node not found")
		return

	# Find skeleton and set up ProceduralAnimator
	var player: Node = $PlayerProcedural
	if player:
		_skeleton = _find_child_of_type(player, "Skeleton3D") as Skeleton3D
		if _skeleton:
			# Create ProceduralAnimator (inactive until preview)
			var animator = ProceduralAnimatorScript.new()
			animator.name = "ProceduralAnimator"
			animator.active = false
			_skeleton.add_child(animator)
			_proc_animator = animator
			print("[PoseTest] ProceduralAnimator added to ", _skeleton.get_path())

			# Disable AnimationPlayer so bridge can drive bones
			var anim_player: AnimationPlayer = _find_child_of_type(player, "AnimationPlayer") as AnimationPlayer
			if anim_player:
				anim_player.active = false

	# Load or create PoseLibrary
	if ResourceLoader.exists(POSE_LIB_PATH):
		_pose_lib = load(POSE_LIB_PATH)
	else:
		_pose_lib = PoseLibraryClass.new()

	if _proc_animator and _pose_lib:
		_proc_animator.pose_lib = _pose_lib
		_proc_animator.hip_drop_max = hip_drop_max
		_sync_tuning_to_animator()

	# Connect bridge signals
	_bridge.connect("pose_captured", _on_pose_captured)
	_bridge.connect("pose_selected", _on_pose_selected)
	_bridge.connect("pose_preview_changed", _on_pose_preview_changed)
	_bridge.connect("pose_library_updated", _on_pose_library_updated)

	# Start server and export
	_bridge.start_server(socket_path)
	if player:
		_bridge.export_scene(player)

	_bridge.auto_sync = true
	_started = true
	print("[PoseTest] Server started — waiting for Blender connection")


func _on_pose_captured(pose_name: String, bone_transforms: Dictionary) -> void:
	if _pose_lib == null:
		return
	# Update the pose slot in memory (no disk save — Godot autosave handles that)
	var poses: Dictionary = _pose_lib.get("poses")
	poses[pose_name] = bone_transforms
	_pose_lib.set("poses", poses)
	pose_count = poses.size()
	# Show which poses have data vs empty
	var status_parts: PackedStringArray = []
	for key in poses.keys():
		var bone_count: int = poses[key].size() if poses[key] is Dictionary else 0
		status_parts.append(str(key) + "(" + str(bone_count) + ")")
	pose_names_list = ", ".join(status_parts) if status_parts.size() > 0 else "(empty)"
	notify_property_list_changed()
	print("[PoseTest] Captured pose '", pose_name, "' with ", bone_transforms.size(), " bones")


func _on_pose_selected(pose_name: String) -> void:
	active_pose = pose_name
	notify_property_list_changed()
	print("[PoseTest] Active pose: '", pose_name, "'")


func _on_pose_preview_changed(active: bool) -> void:
	_preview_active = active
	preview_active = active
	notify_property_list_changed()
	print("[PoseTest] Preview mode: ", "ON" if active else "OFF")

	if active:
		# Save PoseLibrary to disk before preview
		if _pose_lib:
			var dir := DirAccess.open("res://")
			if dir:
				dir.make_dir_recursive("scenes/player/procedural")
			ResourceSaver.save(_pose_lib, POSE_LIB_PATH)
			print("[PoseTest] PoseLibrary saved to ", POSE_LIB_PATH)

		# Enable ProceduralAnimator, disable bridge skeleton import
		if _proc_animator:
			_proc_animator.pose_lib = _pose_lib
			_proc_animator.active = true
	else:
		# Disable ProceduralAnimator, let bridge drive skeleton again
		if _proc_animator:
			_proc_animator.active = false


func _on_pose_library_updated() -> void:
	# Reload from disk if bridge saved it (e.g., from pose_names: command)
	if ResourceLoader.exists(POSE_LIB_PATH):
		_pose_lib = load(POSE_LIB_PATH)
		if _proc_animator:
			_proc_animator.pose_lib = _pose_lib
	var poses: Dictionary = _pose_lib.get("poses") if _pose_lib else {}
	pose_count = poses.size()
	pose_names_list = ", ".join(poses.keys()) if poses.size() > 0 else "(empty)"
	notify_property_list_changed()
	print("[PoseTest] PoseLibrary updated: ", pose_count, " poses — ", pose_names_list)


func _exit_tree() -> void:
	if not Engine.is_editor_hint():
		return
	if _bridge != null and _started:
		_bridge.auto_sync = false
		_bridge.stop_server()
		_started = false
		print("[PoseTest] Server stopped")


func _process(_delta: float) -> void:
	if not Engine.is_editor_hint() or not _started:
		return

	# F6 to re-export
	if Input.is_key_pressed(KEY_F6):
		var player: Node = $PlayerProcedural
		if player:
			_bridge.export_scene(player)
			print("[PoseTest] Re-exported (F6)")

	# Sync tuning sliders to animator every frame (cheap, enables live tweaking)
	if _preview_active and _proc_animator:
		_sync_tuning_to_animator()

	# Feed fake movement to ProceduralAnimator during preview
	if _preview_active and _proc_animator:
		# Simple oscillating walk for preview
		var _t: float = Time.get_ticks_msec() / 1000.0
		_proc_animator.horizontal_speed = preview_speed
		_proc_animator.is_grounded = true
		_proc_animator.vertical_velocity = 0.0


func _find_child_of_type(node: Node, type_name: String) -> Node:
	for child in node.get_children():
		if child.get_class() == type_name:
			return child
		var found: Node = _find_child_of_type(child, type_name)
		if found:
			return found
	return null