4방향 보기 #1082 — 마지막 이동 방향으로 앞/뒤 클립 세트 선택 + 좌우 반전(↘ 기본 · ↙ · ↖ · ↗) · 슬링샷·튕김·밀림·돌진·사격·밀린 아군 공격 시 갱신 · 기병 복귀는 방향 유지 · 테스트 23/23

- UnitView: FacingBack/FacingLeft · SetFacing(세로 성분 → back/front 세트 · 가로 성분 → flipX · 거의 0 인 축은 유지 · 세트가 바뀌면 같은 동작을 새 보기 클립으로 이어 재생) · 시작 = 아군 ↗ · 적군 ↘
- ActionResolver: Face() · Move/MoveStraight/Push/Bounce/플로우/기병 돌진/궁병 사격/밀린 아군 공격에서 호출 · 기병 복귀 Move(face:false)
- 테스트 Facing_FourWays_FollowsLastMoveDirection_BounceTurnsAround(유비 아트 4방향 + 튕김 뒤 아래 보기 + 밀린 적 위 보기)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-04 17:58:30 +09:00
parent 852261b51d
commit 8f71910b6b
3 changed files with 113 additions and 5 deletions

View File

@ -210,6 +210,7 @@ namespace TKS
float travel = Combat.FreeTravel(first, first.Position, dir, remaining, true, true, u); float travel = Combat.FreeTravel(first, first.Position, dir, remaining, true, true, u);
if (travel > 0.01f) if (travel > 0.01f)
{ {
Face(first, dir); // 밀린 유닛은 밀린 방향을 본다(PD #1082)
Vector2 firstEnd = first.Position + dir * travel; Vector2 firstEnd = first.Position + dir * travel;
Vector2 spearEnd = contact + dir * travel; Vector2 spearEnd = contact + dir * travel;
Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null)); Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null));
@ -251,6 +252,7 @@ namespace TKS
yield break; yield break;
} }
Face(u, dir);
if (u.View != null) u.View.PlayAttack(); if (u.View != null) u.View.PlayAttack();
int idx = 0; int idx = 0;
var hit = new List<Unit>(); var hit = new List<Unit>();
@ -291,7 +293,7 @@ namespace TKS
if (willReturn && u.IsAlive) if (willReturn && u.IsAlive)
{ {
yield return Fx.Wait(0.1f); yield return Fx.Wait(0.1f);
yield return Move(u, origin, o); yield return Move(u, origin, o, false); // 복귀는 적을 본 채 물러남(보는 방향 유지 · 총괄PM 해석)
o.Returned = true; o.Returned = true;
} }
} }
@ -386,6 +388,7 @@ namespace TKS
if (hitDist - freeMove <= u.AttackRange + 0.05f) if (hitDist - freeMove <= u.AttackRange + 0.05f)
{ {
bool crit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first; bool crit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
Face(u, dir); // 제자리 사격도 조준 방향을 본다
yield return Attack(u, first, true, crit, o, true); yield return Attack(u, first, true, crit, o, true);
} }
else else
@ -403,8 +406,15 @@ namespace TKS
o.MovedOnly = true; o.MovedOnly = true;
} }
IEnumerator Move(Unit u, Vector2 to, ActionOutcome o) /// <summary>보는 방향 갱신(PD #1082 · 마지막 이동 방향 · 4방향 = 앞/뒤 세트 + 좌우 반전).</summary>
static void Face(Unit u, Vector2 dir)
{ {
if (u != null && u.View != null && dir.sqrMagnitude > 1e-6f) u.View.SetFacing(dir);
}
IEnumerator Move(Unit u, Vector2 to, ActionOutcome o, bool face = true)
{
if (face) Face(u, to - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null); yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null);
u.Position = to; u.Position = to;
o.Path.Add(to); o.Path.Add(to);
@ -413,6 +423,7 @@ namespace TKS
/// <summary>등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.</summary> /// <summary>등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.</summary>
IEnumerator MoveStraight(Unit u, Vector2 to, ActionOutcome o) IEnumerator MoveStraight(Unit u, Vector2 to, ActionOutcome o)
{ {
Face(u, to - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null, false); yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null, false);
u.Position = to; u.Position = to;
o.Path.Add(to); o.Path.Add(to);
@ -453,6 +464,7 @@ namespace TKS
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck); float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
if (back > 0.01f) if (back > 0.01f)
{ {
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082)
Vector2 end = battle.Board.ClampInside(striker.Position - n * back, striker.Radius); Vector2 end = battle.Board.ClampInside(striker.Position - n * back, striker.Radius);
yield return Fx.MoveAlong(striker.transform, striker.Position, end, cfg.moveSpeed, null); yield return Fx.MoveAlong(striker.transform, striker.Position, end, cfg.moveSpeed, null);
striker.Position = end; striker.Position = end;
@ -475,6 +487,7 @@ namespace TKS
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher); float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
if (slide > 0.01f) if (slide > 0.01f)
{ {
Face(target, n); // 밀린 방향을 본다(PD #1082)
Vector2 end = battle.Board.ClampInside(target.Position + n * slide, target.Radius); Vector2 end = battle.Board.ClampInside(target.Position + n * slide, target.Radius);
yield return Fx.MoveAlong(target.transform, target.Position, end, cfg.moveSpeed, null); yield return Fx.MoveAlong(target.transform, target.Position, end, cfg.moveSpeed, null);
target.Position = end; target.Position = end;
@ -490,6 +503,7 @@ namespace TKS
Unit foe = Combat.TouchingOpponent(ally); Unit foe = Combat.TouchingOpponent(ally);
if (foe == null) yield break; if (foe == null) yield break;
yield return Fx.Wait(0.1f); yield return Fx.Wait(0.1f);
Face(ally, foe.Position - ally.Position);
yield return Attack(ally, foe, false, false, o, false); yield return Attack(ally, foe, false, false, o, false);
} }

View File

@ -33,6 +33,9 @@ namespace TKS
Color baseColor; Color baseColor;
Coroutine flash; Coroutine flash;
bool aiming; bool aiming;
bool facingBack; // 위를 봄(뒷모습 back_* 세트) · false = 아래를 봄(앞모습 front_*)
bool facingLeft; // 왼쪽을 봄(좌우 반전) · false = 오른쪽(기본)
const float FacingEps = 0.05f;
/// <summary>스프라이트 아트로 그리는가(false = 도형).</summary> /// <summary>스프라이트 아트로 그리는가(false = 도형).</summary>
public bool HasArt public bool HasArt
@ -45,6 +48,52 @@ namespace TKS
get { return anim; } get { return anim; }
} }
/// <summary>4방향 보기(PD #1082): 위를 보면 뒷모습(↖·↗) · 아래를 보면 앞모습(↙·↘).</summary>
public bool FacingBack
{
get { return facingBack; }
}
/// <summary>왼쪽을 보면 좌우 반전(↙·↖) · 오른쪽이 기본(↘·↗).</summary>
public bool FacingLeft
{
get { return facingLeft; }
}
/// <summary>
/// 마지막 이동 방향으로 보는 방향을 정한다(PD #1082 · 슬링샷·튕김·밀림·돌진 뒤 마지막 방향).
/// 세로 성분이 있으면 앞/뒤 세트를, 가로 성분이 있으면 좌우 반전을 바꾼다(거의 0 인 축은 이전 값 유지).
/// </summary>
public void SetFacing(Vector2 dir)
{
bool viewChanged = false;
if (dir.y > FacingEps && !facingBack)
{
facingBack = true;
viewChanged = true;
}
else if (dir.y < -FacingEps && facingBack)
{
facingBack = false;
viewChanged = true;
}
if (dir.x > FacingEps) facingLeft = false;
else if (dir.x < -FacingEps) facingLeft = true;
if (anim == null) return;
anim.Renderer.flipX = facingLeft;
if (!viewChanged) return;
// 앞/뒤 세트가 바뀌면 지금 동작을 새 보기의 같은 클립으로 이어 간다
string cur = anim.Current ?? "";
string suffix = cur.StartsWith("back_") ? cur.Substring(5) : (cur.StartsWith("front_") ? cur.Substring(6) : "idle");
anim.SetIdle(ClipName("idle"));
if (suffix == "idle") anim.PlayIdle();
else if (suffix == "ready") anim.Play(ClipName("ready"), true);
else if (suffix == "victory") anim.Play(ClipName("victory"), false);
else anim.Play(ClipName(suffix), false, AttackHold, true);
}
public void Build(Unit u) public void Build(Unit u)
{ {
unit = u; unit = u;
@ -65,6 +114,10 @@ namespace TKS
body = ProceduralSprites.NewSprite(transform, "Body", ProceduralSprites.Circle, baseColor, d, 0); body = ProceduralSprites.NewSprite(transform, "Body", ProceduralSprites.Circle, baseColor, d, 0);
// 시작 보기: 아군 = 위(↗ 뒷모습) · 적군 = 아래(↘ 앞모습) · 이후는 마지막 이동 방향(PD #1082)
facingBack = u.Faction == Faction.Player;
facingLeft = false;
// 아트가 있으면 몸통 원 대신 스프라이트 애니(발 = 원 아래쪽) // 아트가 있으면 몸통 원 대신 스프라이트 애니(발 = 원 아래쪽)
anim = null; anim = null;
var art = UnitArt.Load(u.ArtId); var art = UnitArt.Load(u.ArtId);
@ -76,6 +129,7 @@ namespace TKS
ago.transform.localPosition = new Vector3(0f, -u.Radius * 0.55f, 0f); ago.transform.localPosition = new Vector3(0f, -u.Radius * 0.55f, 0f);
anim = ago.AddComponent<FrameAnimator>(); anim = ago.AddComponent<FrameAnimator>();
anim.Setup(art, u.ArtHeight, 0); anim.Setup(art, u.ArtHeight, 0);
anim.Renderer.flipX = facingLeft;
anim.SetIdle(ClipName("idle")); anim.SetIdle(ClipName("idle"));
anim.PlayIdle(); anim.PlayIdle();
} }
@ -105,11 +159,11 @@ namespace TKS
Refresh(); Refresh();
} }
/// <summary>보기(아군 = back · 적군 = front)에 맞는 클립 이름. 그 보기에 없으면 다른 보기 → 대기.</summary> /// <summary>보는 방향(위 = back · 아래 = front)에 맞는 클립 이름. 그 보기에 없으면 다른 보기 → 대기.</summary>
string ClipName(string suffix) string ClipName(string suffix)
{ {
string v = unit.Faction == Faction.Player ? "back" : "front"; string v = facingBack ? "back" : "front";
string o = unit.Faction == Faction.Player ? "front" : "back"; string o = facingBack ? "front" : "back";
if (anim.Has(v + "_" + suffix)) return v + "_" + suffix; if (anim.Has(v + "_" + suffix)) return v + "_" + suffix;
if (anim.Has(o + "_" + suffix)) return o + "_" + suffix; if (anim.Has(o + "_" + suffix)) return o + "_" + suffix;
return anim.Has(v + "_idle") ? v + "_idle" : o + "_idle"; return anim.Has(v + "_idle") ? v + "_idle" : o + "_idle";

View File

@ -501,5 +501,45 @@ namespace TKS.Tests
bm.StartStage(0); bm.StartStage(0);
yield return null; yield return null;
} }
[UnityTest]
public IEnumerator Facing_FourWays_FollowsLastMoveDirection_BounceTurnsAround()
{
yield return Fresh();
// PD #1082: 오른쪽 보기(기본) ↘ · 왼쪽 ↙ · 왼쪽 위 ↖ · 오른쪽 위 ↗ = 앞/뒤 세트 + 좌우 반전 · 보는 방향 = 마지막 이동 방향
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -5f), "유비", 1f, 1f, false, "liubei");
Assert.IsTrue(u.View.HasArt);
Assert.IsTrue(u.View.FacingBack && !u.View.FacingLeft, "아군 시작 = ↗(위 · 오른쪽)");
Assert.AreEqual("back_idle", u.View.Anim.Current);
yield return bm.ResolveForTest(u, u.Position + new Vector2(2f, 2f), false, new ActionOutcome());
Assert.IsTrue(u.View.FacingBack && !u.View.FacingLeft, "↗ 이동 → 뒷모습 · 반전 없음");
Assert.IsFalse(u.View.Anim.Renderer.flipX);
yield return bm.ResolveForTest(u, u.Position + new Vector2(-2f, 2f), false, new ActionOutcome());
Assert.IsTrue(u.View.FacingBack && u.View.FacingLeft, "↖ 이동 → 뒷모습 · 좌우 반전");
Assert.IsTrue(u.View.Anim.Renderer.flipX);
Assert.AreEqual("back_idle", u.View.Anim.Current);
yield return bm.ResolveForTest(u, u.Position + new Vector2(-2f, -2f), false, new ActionOutcome());
Assert.IsTrue(!u.View.FacingBack && u.View.FacingLeft, "↙ 이동 → 앞모습 · 좌우 반전");
Assert.AreEqual("front_idle", u.View.Anim.Current, "앞/뒤 세트가 바뀌면 대기 클립도 바뀐다");
yield return bm.ResolveForTest(u, u.Position + new Vector2(2f, -2f), false, new ActionOutcome());
Assert.IsTrue(!u.View.FacingBack && !u.View.FacingLeft, "↘ 이동 → 앞모습 · 반전 없음");
Assert.IsFalse(u.View.Anim.Renderer.flipX);
yield return bm.ResolveForTest(u, u.Position + new Vector2(0f, 2f), false, new ActionOutcome());
Assert.IsTrue(u.View.FacingBack && !u.View.FacingLeft, "곧장 위로 → 뒷모습 · 좌우는 이전 값 유지");
// 위의 적을 쳐서 튕겨 나오면(아래로) 아래를 보고, 밀린 적은 밀린 방향(위)을 본다
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, u.Position + new Vector2(0f, 2.5f), "적보병");
Assert.IsFalse(foe.View.FacingBack, "적 시작 = ↘(아래)");
var o = new ActionOutcome();
yield return bm.ResolveForTest(u, foe.Position, false, o);
Assert.Contains(foe, o.Pushed);
Assert.IsFalse(u.View.FacingBack, "튕겨 나와 방향이 바뀌면 그 방향(아래)을 본다");
Assert.IsTrue(foe.View.FacingBack, "밀린 적은 밀린 방향(위)을 본다");
}
} }
} }