Skip to content

Instantly share code, notes, and snippets.

@seobyeongky
Created February 14, 2026 04:44
Show Gist options
  • Select an option

  • Save seobyeongky/a132ee00ad3307d8f2359a56ee12066e to your computer and use it in GitHub Desktop.

Select an option

Save seobyeongky/a132ee00ad3307d8f2359a56ee12066e to your computer and use it in GitHub Desktop.
using System;
using System.Collections.Generic;
using Data;
using MovementEffects;
using Sirenix.OdinInspector;
using UnityEngine;
[CreateAssetMenu(fileName = "FootWalk", menuName = "FootAnim/FootWalk")]
public class FootGaitAnimation : FootAnimation
{
[Header("Gait Settings")]
public FootGaitConfig walk = new();
public FootGaitConfig run = new();
public FootGaitConfig sprint = new();
#if UNITY_EDITOR
[Button]
public void OpenTable()
{
var opts = new TableEditorShowOption();
opts.AddScriptableObject(this, "walk");
opts.AddScriptableObject(this, "run");
opts.AddScriptableObject(this, "sprint");
TableEditorUtils.Show(opts);
}
#endif
[Header("Gait Blending")]
[Range(0f, 1f)]
public float gaitBlend01;
FootGaitConfig cfgcfg;
public void SetGaitBlend(float blend01) => gaitBlend01 = Mathf.Clamp01(blend01);
public float EvaluateGaitBlendT(float? overwrite = null) => (overwrite ?? gaitBlend01);
public FootGaitConfig GetBlendedConfig(float? overwrite = null)
{
cfgcfg ??= new FootGaitConfig();
var t = EvaluateGaitBlendT(overwrite);
if (t == 0)
return walk;
else if (t < 0.5f)
cfgcfg.Interpolate(walk, run, t * 2f);
else if (t == 0.5f)
return run;
else if (t < 1f)
cfgcfg.Interpolate(run, sprint, (t - 0.5f) * 2f);
else
return sprint;
return cfgcfg;
}
public override PlayContextBase CreateContext() => new GaitContext(this);
struct FootVisualTracking
{
public float trackStartTime;
public float progressAtStart;
public void Check(float pr)
{
trackStartTime = GodOfTime.time;
progressAtStart = pr;
}
public float GetProgress(float duration)
{
return progressAtStart + (GodOfTime.time - trackStartTime) / duration;
}
}
public class GaitContext : PlayContextBase
{
/// <summary>
/// 0~1값
/// </summary>
public float gaitProgress;
FootGaitAnimation Outer => (FootGaitAnimation)base.outer;
private FootGaitConfig _gaitCfg = null!;
private CoroutineHandle _walkRoutine;
private CoroutineHandle coStand;
private CoroutineHandle coSwing;
private CoroutineHandle co3;
CoroutineHandle coBodyMove;
CoroutineHandle coSetVisual;
CoroutineHandle coDbg;
private int _currentFootIndex = 0; // 0: Left, 1: Right
FootVisualTracking movingFootVisualTracking;
FootVisualTracking standingFootVisualTracking;
float bodyMoveRatio = 0f;
public GaitContext(FootGaitAnimation outer) : base(outer) { }
protected override void OnStart(Vector2 movingDir)
{
_gaitCfg = Outer.GetBlendedConfig(Animator.gaitBlendOverwrite);
var customLegInfo = _gaitCfg.customLegInfo;
if (Animator.DirSign < 0)
{
customLegInfo.leftOriginOffsetXy.x += 0.02f;
customLegInfo.rightOriginOffsetXy.x += 0.02f;
}
Animator.targetLegInfo = customLegInfo;
foreach (var foot in foots)
{
foot.MoveDelta(Vector3.zero, 0.05f, FootPosLayer.DashLift);
foot.MoveDelta(Vector3.zero, 0.1f, FootPosLayer.LadderOuterOffset);
foot.MoveDelta(Vector3.zero, 0.1f, FootPosLayer.RopeClimbOffset, EaseSetting.EaseOutQuad);
foot.groundAbsWeight = _gaitCfg.groundAbsWeight;
foot.SetRotationDelta(0f, 0.05f, FootRotLayer.Base);//대쉬로부터 초기화
foot.SetVisual(FootVisualType.Step, 0f);
}
_walkRoutine = Timing.RunCoroutine(WalkRoutine(), Animator.FootAnimCoroutineTag);
bodyMoveRatio = 0;
Animator.SetFootSortingMode(FootSortingMode.FrontFootFront);
}
protected override void OnEnd()
{
Animator.ResetFootSortingMode();
if (_walkRoutine.IsInitialized)
Timing.KillCoroutinesSafe(ref _walkRoutine);
foreach (var foot in foots)
foot.SetDebugColor(Color.white);
if (coStand.IsInitialized)
Timing.KillCoroutinesSafe(ref coStand);
if (coSwing.IsInitialized)
Timing.KillCoroutinesSafe(ref coSwing);
if (co3.IsInitialized)
Timing.KillCoroutinesSafe(ref co3);
if (coBodyMove.IsInitialized)
Timing.KillCoroutinesSafe(ref coBodyMove);
if (coSetVisual.IsInitialized)
Timing.KillCoroutinesSafe(ref coSetVisual);
if (coDbg.IsInitialized)
Timing.KillCoroutinesSafe(ref coDbg);
Animator.Owner.shape.frontSortingGroupPos.def.UnsetWithDamp(DefinitePositionKey.WalkAnimation, 0.1f);
}
protected override void OnUpdate(float dt, Vector2 movingDir, float advanceScale, Vector3 displ)
{
_gaitCfg = Outer.GetBlendedConfig(Animator.gaitBlendOverwrite);
if (Global.dbgOverlay.IsSubscribed(DebugOverlayChannel.Gait))
Global.dbgOverlay.Log($"gaitBlend:{Outer.EvaluateGaitBlendT(Animator.gaitBlendOverwrite)}");
}
IEnumerator<float> WalkRoutine()
{
bool isFirstStep = true;
while (true)
{
_gaitCfg = Outer.GetBlendedConfig(Animator.gaitBlendOverwrite);
foreach (var foot in foots)
foot.groundAbsWeight = _gaitCfg.groundAbsWeight;
if (isFirstStep)
{
_currentFootIndex = SelectMovingFoot();
isFirstStep = false;
}
// 현재 발, 반대 발
var movingFoot = foots[_currentFootIndex];
var standingFoot = foots[1 - _currentFootIndex];
// D.Log($"move {_currentFootIndex}, stand {1 - _currentFootIndex}");
coStand = Timing.RunCoroutine(RoutineStandingFoot(standingFoot, _gaitCfg), Animator.FootAnimCoroutineTag);
coSwing = Timing.RunCoroutine(RoutineSwingFoot(movingFoot, _gaitCfg), Animator.FootAnimCoroutineTag);
coBodyMove = Timing.RunCoroutine(RoutineBodyMove(_gaitCfg), Animator.FootAnimCoroutineTag);
coSetVisual = Timing.RunCoroutine(RoutineSetVisual(movingFoot, standingFoot, _gaitCfg), Animator.FootAnimCoroutineTag);
// coDbg = Timing.RunCoroutine(RoutineDebug(_gaitCfg), Animator.FootAnimCoroutineTag);
yield return Timing.WaitUntilDone(coSwing);
while (standingFoot.landPose)
yield return Timing.WaitForOneFrame; // 아직 발에 땅이 떼지기 전에는
if (coStand.IsInitialized)
Timing.KillCoroutinesSafe(ref coStand);
// 발 교체
_currentFootIndex = 1 - _currentFootIndex;
}
}
IEnumerator<float> RoutineSwingFoot(FootBone foot, FootGaitConfig cfg)
{
float dirSign = Animator.DirSign;
movingFootVisualTracking.Check(0);
if (foot.dbgCycle)
D.Log($"[{foot.Index},{Time.frameCount}] swing start");
//초기화 (와리가리할 때 이전 모션 남아있음)
foot.MoveDelta(Vector3.zero, 0.1f, FootPosLayer.PushLift);
foot.MoveDelta(Vector3.zero, 0.1f, FootPosLayer.PushBack);
var reachForward = foot.MoveForward(cfg.reachForward * dirSign, cfg.interval * cfg.swingReachRatio, FootPosLayer.Base, cfg.reachForwardEase);
co3 = RunDelayScaled(cfg.reachRotateDelay, () =>
{
foot.SetRotationDelta(cfg.reachRotate, FootRotLayer.Base, isPlaceholder:true);
});
while (!reachForward.IsDone)
{
float t = reachForward.Progress01;
float lift = cfg.reachLiftCurve.Evaluate(t) * cfg.reachLiftHeight;
foot.SetLayerOffset(FootPosLayer.SwingLift, new Vector3(0, 0, -lift));
yield return Timing.WaitForOneFrame;
}
// 2. 발 내리기 (Land) -> 지형 높이 고려
if (foot.dbgCycle)
D.Log($"[{foot.Index},{Time.frameCount}] swing land");
FootGroundInfo ground = foot.GetGroundInfo();
var landMotionTime = cfg.interval * (1 - cfg.swingReachRatio);
var landMotion = foot.MoveForward(cfg.landForward * dirSign, landMotionTime, FootPosLayer.Base, cfg.landForwardEase, true);
foot.MoveDelta(Vector3.zero, landMotionTime, FootPosLayer.SwingLift, cfg.landLiftSettleEase);
foot.SetRotationDelta(0f, landMotionTime,FootRotLayer.Base, cfg.landRotateSettleEase, isPlaceholder:true);
while (!landMotion.IsDone)
{
yield return Timing.WaitForOneFrame;
}
coSwing = default;
}
IEnumerator<float> RoutineStandingFoot(FootBone foot, FootGaitConfig cfg)
{
float dirSign = Animator.DirSign;
standingFootVisualTracking.Check(0);
if (foot.dbgCycle)
D.Log($"[{foot.Index},{Time.frameCount}] standing start");
float visElapsed = 0f;
if (!foot.WillLandPoseOrLandPosed)
{
var m = foot.MoveForward(0, 0.03f, FootPosLayer.Base, toLand:true);
yield return WaitUntil(() => m.IsDone);
}
var moveBackSpeed = (cfg.stanceHoldBackDistance / (cfg.interval * cfg.stanceHoldRatio));
bool shouldPush = false;
float holdTime = cfg.stanceHoldRatio * cfg.interval;
if (holdTime > 0f)
{
float elapsed = 0f;
while (elapsed < holdTime)
{
elapsed += ScaledDt;
visElapsed += ScaledDt;
shouldPush = ShouldPush();
if (shouldPush)
break;
var backDir = -new Vector2(foot.FlipDirX, 0) * Animator.DirSign;
foot.MoveLayerOffset(FootPosLayer.Base, backDir.xy0() * moveBackSpeed * Timing.DeltaTime);
yield return Timing.WaitForOneFrame;
}
}
visElapsed = holdTime; //중간 탈출 처리
standingFootVisualTracking.Check(visElapsed / cfg.interval);
// foot.SetVisual(FootVisualType.Step, visElapsed / cfg.interval);
while (NotOkToPush())
{
var backDir = -new Vector2(foot.FlipDirX, 0) * Animator.DirSign;
foot.MoveLayerOffset(FootPosLayer.Base, backDir.xy0() * moveBackSpeed * Timing.DeltaTime);
foot.SetVisual(FootVisualType.Step, visElapsed / cfg.interval);
visElapsed += ScaledDt;
// D.Log("Not ok to swing yet");
yield return Timing.WaitForOneFrame;
}
bool NotOkToPush() // 시간이 다했음에도 발을 떄지 말아야하는 경우를 위한 체크 (아직 발이 충분히 뒤로 가지 않은 경우임)
{
foot.GetLegCalcInfo(out _, out _, out float angle0);
return angle0 * Mathf.Rad2Deg * dirSign > -cfg.pushNotOkAngle;
}
bool ShouldPush()//시간다하기 전에 뛰어야하는 케이스 (각도 어색 문제 방지용)
{
foot.GetLegCalcInfo(out _, out _, out float angle0);
return angle0 * Mathf.Rad2Deg * dirSign < -cfg.pushShouldAngle;
}
if (foot.dbgCycle)
D.Log($"[{foot.Index},{Time.frameCount}] standing kick");
foot.CancelMove(FootPosLayer.Base); // isGrounded를 세팅해버리기 때문에 멈추기
foot.SetRotationDelta(cfg.pushRotate, FootRotLayer.Base, isPlaceholder:true);
foot.UnsetLandPose();
foot.MoveDelta(new Vector3(0, 0, -cfg.pushLiftHeight), cfg.pushLiftTime, FootPosLayer.PushLift, cfg.pushEase); // 발 약간 올리기
foot.MoveForward(-cfg.pushBackDistance * dirSign, cfg.pushBackTime, FootPosLayer.PushBack, cfg.pushEase); // 발 뒤로 밀기
yield return WaitScaledSeconds(cfg.pushSettleDelay);
foot.MoveDelta(Vector3.zero, cfg.pushLiftSettleTime, FootPosLayer.PushLift); // 발 내리기
foot.MoveForward(0f, cfg.pushBackSettleTime, FootPosLayer.PushBack); // 발 멈추기
foot.SetRotationDelta(0f, cfg.pushRotateSettleTime, FootRotLayer.Base, isPlaceholder:true);
foot.MoveForward(cfg.pushBackSettleForward * dirSign, cfg.pushBackSettleTime, FootPosLayer.Base);//약간앞전진
coStand = default;
}
IEnumerator<float> RoutineBodyMove(FootGaitConfig cfg)
{
float elapsed = 0f;
var def = Animator.Owner.shape.frontSortingGroupPos.def;
while (elapsed < cfg.interval)
{
if (bodyMoveRatio < 1)
bodyMoveRatio += ScaledDt / cfg.bodyMoveRatioRecoverTime;
float t = elapsed / cfg.interval - cfg.bodyMoveStartOffsetRatio;
if (t < 0)
t += 1;
float moveAmount = cfg.bodyMoveCurve.Evaluate(t) * cfg.bodyMoveScale * bodyMoveRatio;
def.Set(DefinitePositionKey.WalkAnimation, new Vector3(0, moveAmount, 0));
elapsed += ScaledDt;
gaitProgress = elapsed / cfg.interval;
yield return Timing.WaitForOneFrame;
}
coBodyMove = default;
}
int SelectMovingFoot()
{
Span<float> footForwardAngles = stackalloc float[2];
for (int i = 0; i < 2; i++)
{
foots[i].GetLegCalcInfo(out Vector3 legOrigin, out float tension, out float legAngle);
footForwardAngles[i] = legAngle;
}
int ret = footForwardAngles[0] < footForwardAngles[1] ? 0 : 1;
if (Animator.DirSign < 0)
ret = 1 - ret;
return ret;
}
IEnumerator<float> RoutineSetVisual(FootBone movingFoot, FootBone standingFoot, FootGaitConfig cfg)
{
float ela = 0f;
while (true)
{
ela += ScaledDt;
if (ela >= cfg.interval)
break;
float movingPr = movingFootVisualTracking.GetProgress(cfg.interval);
float standingPr = standingFootVisualTracking.GetProgress(cfg.interval);
movingFoot.SetVisual(FootVisualType.GaitSwing, Mathf.Clamp01(movingPr));
standingFoot.SetVisual(FootVisualType.Step, Mathf.Clamp01(standingPr));
yield return Timing.WaitForOneFrame;
}
coSetVisual = default;
}
IEnumerator<float> RoutineDebug(FootGaitConfig cfg)
{
float ela = 0f;
while (true)
{
ela += ScaledDt;
Global.dbgOverlay.Log($"t : {ela / cfg.interval}");
if (ela >= cfg.interval)
break;
yield return Timing.WaitForOneFrame;
}
coDbg = default;
}
}
}
using System;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Serialization;
[Serializable, Frostory.AutoLerp.AutoLerp]
public partial class FootGaitConfig
{
[Header("General Settings")]
public float interval;
public float groundAbsWeight = 0.7f;
public CustomLegInfo customLegInfo;
[Header("Swing Reach")]
public float swingReachRatio = 0.85f;
public float reachForward = 0.2f;
public EaseSetting reachForwardEase;
public float reachLiftHeight;
public AnimationCurve reachLiftCurve = new AnimationCurve(new Keyframe(0, 0), new Keyframe(0.5f, 1), new Keyframe(1, 0));
public float reachRotateDelay = 0.05f;
public FootRotateMotion reachRotate;
[Header("Swing Land")]
public float landForward = 0.16f;
public EaseSetting landForwardEase;
public EaseSetting landLiftSettleEase;
public EaseSetting landRotateSettleEase;
[Header("Stance Hold")]
public float stanceHoldRatio = 0.5f;
public float stanceHoldBackDistance = 0; // 기본적으로 groundAbsWeight에 의해 밀리지만, 추가적으로 밀고싶을 때
[Header("Stance Push")]
public EaseSetting pushEase;
public float pushLiftHeight = 0.05f; // +값(적용은 -Z)
public float pushLiftTime = 0.1f;
public float pushLiftSettleTime = 0.03f;
public float pushBackTime = 0.03f;
public float pushBackDistance = 0.05f; // +값(적용은 -forward)
public float pushSettleDelay = 0.2f;
public float pushBackSettleTime = 0.1f;
public float pushBackSettleForward = -0.1f;
public FootRotateMotion pushRotate;
public float pushRotateSettleTime = 0.1f;
[Header("Body Move")]
public float bodyMoveRatioRecoverTime = 0.1f;
public float bodyMoveStartOffsetRatio = -0.3f; // 음수로 offset세팅하기
public AnimationCurve bodyMoveCurve = new AnimationCurve();
public float bodyMoveScale = 0.05f;
[FormerlySerializedAs("pushOkAngle")] [Header("ETC")]
public float pushNotOkAngle = 20f; // 아직 변위가 다하지 못했을 때 기다려주는 최소값
public float pushShouldAngle = 45f; // 변위가 다했을 때 발을 때는 각도
[Button]
public void ScaleTimes(float factor)
{
interval *= factor;
reachRotateDelay *= factor;
reachRotate.time *= factor;
pushLiftTime *= factor;
pushLiftSettleTime *= factor;
pushBackTime *= factor;
pushSettleDelay *= factor;
pushBackSettleTime *= factor;
pushRotate.time *= factor;
pushRotateSettleTime *= factor;
bodyMoveRatioRecoverTime *= factor;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment