extends Node ## ## TimelineModule - keyframe timeline for objects and bones (Features 81-100). ## ## Backed by a real Godot Animation resource + AnimationPlayer, so playback, ## interpolation and looping use engine-native code (fast, cached). The module ## exposes a touch-friendly API: scrub(), add_keyframe(), play(), pause(). ## @export var duration: float = 5.0 @export var fps: int = 30 var _host: Node = null var _player: AnimationPlayer = null var _anim: Animation = null var _playhead: float = 0.0 var _playing := false var _key_count := 0 func _activate(host: Node) -> void: _host = host _ensure_player() print("[Timeline] activated (%.1fs @ %d fps)" % [duration, fps]) func _deactivate() -> void: _playing = false _player = null _anim = null _host = null func _ensure_player() -> void: if _player != null and is_instance_valid(_player): return _anim = Animation.new() _anim.length = duration _anim.step = 1.0 / float(fps) _player = AnimationPlayer.new() _player.name = "TimelinePlayer" if _host != null: _host.add_child(_player) var lib := AnimationLibrary.new() lib.add_animation("main", _anim) _player.add_animation_library("", lib) _player.animation_finished.connect(_on_finished) func _process(delta: float) -> void: if not _playing or _player == null: return _playhead = _player.current_animation_position StudioBus.timeline_playhead.emit(_playhead) ## --- Playback --------------------------------------------------------------- func play(from: float = -1.0) -> void: _ensure_player() if from >= 0.0: _player.seek(from, true) _player.play("main") _playing = true StudioBus.playback_state_changed.emit(true) func pause() -> void: if _player != null: _player.pause() _playing = false StudioBus.playback_state_changed.emit(false) func stop() -> void: if _player != null: _player.stop() _playhead = 0.0 _playing = false StudioBus.playback_state_changed.emit(false) ## Jump the playhead to `time` seconds (scrubbing). func scrub(time: float) -> void: _ensure_player() time = clampf(time, 0.0, duration) _playhead = time _player.seek(time, true) StudioBus.timeline_playhead.emit(time) func is_playing() -> bool: return _playing func playhead() -> float: return _playhead ## --- Keyframes -------------------------------------------------------------- ## Add a keyframe for a `property` (NodePath-style) on an object at `time`. ## For bones use property like "bones/Head:position" (Skeleton3D pose tracks). func add_keyframe(target_node: Node3D, property: String, time: float, value: Variant) -> void: _ensure_player() time = clampf(time, 0.0, duration) var path := _player.get_path_to(target_node) if path == NodePath(): push_warning("Timeline: target is not under the player root") return var track := _find_or_create_track(String(path), property, value) var key_idx := _anim.track_insert_key(track, time, value, 0.0) if key_idx >= 0: _key_count += 1 StudioBus.keyframe_added.emit(String(path), property, time) StudioBus.scene_modified.emit() func _find_or_create_track(node_path: String, property: String, value: Variant) -> int: var full := NodePath(node_path + ":" + property) for i in range(_anim.get_track_count()): if _anim.track_get_path(i) == full: return i var type := Animation.TYPE_VALUE if value is Transform3D: type = Animation.TYPE_VALUE _anim.add_track(type) var idx := _anim.get_track_count() - 1 _anim.track_set_path(idx, full) if value is Vector3: _anim.track_set_interpolation_type(idx, Animation.INTERPOLATION_CUBIC) return idx ## Convenience: key every transform channel of a bone of a Skeleton3D. ## Godot stores bone poses as 3D transforms, so we key position/rotation/scale. func add_bone_keyframe(skeleton: Skeleton3D, bone_name: String, time: float) -> void: if skeleton == null: return var bone_idx := skeleton.find_bone(bone_name) if bone_idx < 0: push_warning("Timeline: bone '%s' not found" % bone_name) return var skin_path := "%s:%s" % [_player.get_path_to(skeleton), bone_name] var pose := skeleton.get_bone_pose(bone_idx) add_keyframe(skeleton, "bones/" + bone_name + "/position", time, pose.origin) # Store rotation as quaternion for clean interpolation. add_keyframe(skeleton, "bones/" + bone_name + "/rotation", time, pose.basis.get_rotation_quaternion()) add_keyframe(skeleton, "bones/" + bone_name + "/scale", time, pose.basis.get_scale()) func delete_keyframe(track: int, key: int) -> void: if track >= 0 and track < _anim.get_track_count(): _anim.track_remove_key(track, key) _key_count = maxi(0, _key_count - 1) func track_count() -> int: return _anim.get_track_count() if _anim != null else 0 func key_count() -> int: return _key_count func set_duration(seconds: float) -> void: duration = maxf(0.1, seconds) _ensure_player() _anim.length = duration func _on_finished() -> void: _playing = false StudioBus.playback_state_changed.emit(false)