extends SceneTree ## Regression for natural authored locomotion transitions. ## Gameplay velocity remains responsive, but the visual BlendSpace must ease ## into acceleration, direction changes and idle instead of teleporting the ## skeleton between unrelated poses on one frame. const RESPONSE := 60.0 const TOP := 11.0 const DT := 1.0 / 60.0 const RUN_FRAMES := 48 const STOP_FRAMES := 60 var _frames := 0 var _model: SkinnedPlayerModel var _speed := 0.0 var _first_speed := 0.0 var _first_stride := 0.0 var _settled_run_stride := 0.0 var _first_stop_stride := 0.0 var _mid_stop_stride := 0.0 var _settled_stop_stride := 0.0 var _largest_stride_step := 0.0 var _previous_stride := 0.0 var _foot_bone := -1 var _foot_rotation_start := Quaternion.IDENTITY var _foot_rotation_delta := 0.0 func _initialize() -> void: var args := OS.get_cmdline_user_args() var path: String = args[0] if not args.is_empty() \ else "res://assets/characters/skins/kiyoko.glb" var scene := Node3D.new() root.add_child(scene) current_scene = scene _model = SkinnedPlayerModel.new() _model.model_path = path scene.add_child(_model) func _process(_delta: float) -> bool: _frames += 1 if _frames < 8 or not _model.loaded: return false if _frames == 8: _model.set_process(false) if _model._spring_mod: _model._spring_mod.lod = 3 _model._spring_mod = null _foot_bone = _model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"]) var sample := _frames - 8 if sample < RUN_FRAMES: _speed = lerpf(_speed, TOP, 1.0 - exp(-RESPONSE * DT)) else: _speed = 0.0 _model.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0) _model.update_state("ground", _speed, false) _model._process(DT) var stride := _model.locomotion_effective_speed_debug() _largest_stride_step = maxf(_largest_stride_step, absf(stride - _previous_stride)) _previous_stride = stride if _foot_bone >= 0: var foot_rotation := _model.skeleton.get_bone_pose_rotation(_foot_bone) if sample == 1: _foot_rotation_start = foot_rotation elif sample > 1 and sample < RUN_FRAMES: _foot_rotation_delta = maxf(_foot_rotation_delta, _foot_rotation_start.angle_to(foot_rotation)) if sample == 0: _first_speed = _speed _first_stride = stride elif sample == RUN_FRAMES - 1: _settled_run_stride = stride elif sample == RUN_FRAMES: _first_stop_stride = stride elif sample == RUN_FRAMES + 12: _mid_stop_stride = stride elif sample == RUN_FRAMES + STOP_FRAMES: _settled_stop_stride = stride _finish() return true return false func _finish() -> void: _model.update_state("dash", 13.0, false) var dash_crossfade := _model._state_trans.xfade_time _model.update_state("ground", 0.0, false) var ground_crossfade := _model._state_trans.xfade_time print("\n=== NATURAL LOCOMOTION TRANSITIONS ===") print(" first frame body %.3f, visual stride %.3f" % [_first_speed, _first_stride]) print(" settled run %.3f, stop %.3f -> %.3f -> %.3f" % [ _settled_run_stride, _first_stop_stride, _mid_stop_stride, _settled_stop_stride]) print(" largest visual stride step %.3f m/s" % _largest_stride_step) print(" traversal/ground crossfades %.3f / %.3f s" % [ dash_crossfade, ground_crossfade]) var failures: Array[String] = [] if _first_speed < 6.0: failures.append("test did not reproduce high gameplay acceleration") if _first_stride <= 0.15 or _first_stride >= _first_speed * 0.65: failures.append("acceleration visual either froze or snapped on frame one") if absf(_settled_run_stride - TOP) > 0.30: failures.append("visual stride did not converge to physical run speed") if _first_stop_stride < _settled_run_stride * 0.65: failures.append("run pose snapped too close to idle on the stop frame") if _mid_stop_stride >= _first_stop_stride or _mid_stop_stride < 0.5: failures.append("run-to-idle recovery is not a progressive ease") if _settled_stop_stride > 0.30: failures.append("run-to-idle recovery did not settle") if _largest_stride_step > 2.5: failures.append("a rendered frame contains an excessive gait jump") if dash_crossfade < 0.19 or ground_crossfade < 0.28: failures.append("full-body state crossfades are still too abrupt") if _foot_bone < 0 or _foot_rotation_delta < 0.03: failures.append("authored foot animation did not move") if failures.is_empty(): print("TRANSITION_CHECK PASS: responsive gameplay, eased animation\n") quit(0) else: for failure in failures: push_error("TRANSITION_CHECK FAIL: " + failure) quit(1)