diff --git a/Assets/Resources/BalanceConfig.asset b/Assets/Resources/BalanceConfig.asset
index 2bc05e5f..83bb80a6 100644
--- a/Assets/Resources/BalanceConfig.asset
+++ b/Assets/Resources/BalanceConfig.asset
@@ -70,6 +70,7 @@ MonoBehaviour:
archerMeleeDealtRatio: 0.5
archerMeleeTakenRatio: 1.5
collisionPushRatio: 1
+ collisionSelfBounceRatio: 0.5
unitArtHeight: 1.5
boardArt: battlefield_01
minDragDistance: 0.35
diff --git a/Assets/Resources/Board/battlefield_01.png b/Assets/Resources/Board/battlefield_01.png
index 4917d126..a9adbc9f 100644
Binary files a/Assets/Resources/Board/battlefield_01.png and b/Assets/Resources/Board/battlefield_01.png differ
diff --git a/Assets/Scripts/Editor/BalanceDefaults.cs b/Assets/Scripts/Editor/BalanceDefaults.cs
index 2b4c902c..9288ba6e 100644
--- a/Assets/Scripts/Editor/BalanceDefaults.cs
+++ b/Assets/Scripts/Editor/BalanceDefaults.cs
@@ -45,6 +45,7 @@ namespace TKS.EditorTools
c.archerMeleeDealtRatio = 0.5f; // PD 2026-11-04 #1080: 궁수 근접 전투 피해 50 %
c.archerMeleeTakenRatio = 1.5f; // PD 2026-11-04 #1080: 궁수 근접 피격 +50 %
c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로
+ c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상)
c.unitArtHeight = 1.5f;
c.boardArt = "battlefield_01"; // 원화 세션 1차 납품(2026-11-04) · Resources/Board/
c.minDragDistance = 0.35f;
diff --git a/Assets/Scripts/Runtime/ActionResolver.cs b/Assets/Scripts/Runtime/ActionResolver.cs
index b555fb77..68e5d170 100644
--- a/Assets/Scripts/Runtime/ActionResolver.cs
+++ b/Assets/Scripts/Runtime/ActionResolver.cs
@@ -146,14 +146,14 @@ namespace TKS
}
Vector2 contact = origin + dir * hitDist;
- yield return Move(u, contact, o); // 부딪힌 지점에서 멈춤
+ yield return MoveStraight(u, contact, o); // 등속으로 가서 부딪힌 순간 바로 반응(PD #1081)
float remaining = Mathf.Max(0f, dist - hitDist);
bool enemy = first.Faction != u.Faction;
bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
if (enemy)
{
- yield return Attack(u, first, false, false, o, true);
+ ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음)
if (!u.IsAlive) yield break;
}
else
@@ -161,14 +161,11 @@ namespace TKS
o.MovedOnly = true;
}
- if (first.IsAlive)
- {
- // 알까기: 접촉 방향(행동 유닛 중심 → 부딪힌 유닛 중심)으로 남은 힘만큼 밀어낸다
- Vector2 n = first.Position - contact;
- n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
- yield return Push(first, n, remaining * cfg.collisionPushRatio, u, o);
- if (!enemy) yield return PushedAllyAttack(first, o);
- }
+ // 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio
+ Vector2 n = first.Position - contact;
+ n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
+ yield return Bounce(u, first, n, remaining, o);
+ if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o);
if (isFinal && u.IsAlive) o.BonusMove = true;
}
@@ -192,14 +189,14 @@ namespace TKS
}
Vector2 contact = origin + dir * hitDist;
- yield return Move(u, contact, o);
+ yield return MoveStraight(u, contact, o); // 등속 접근 → 부딪힌 순간 즉시 밀기 시작(PD #1081)
float remaining = Mathf.Max(0f, dist - hitDist);
bool enemy = first.Faction != u.Faction;
bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
if (enemy)
{
- yield return Attack(u, first, false, false, o, true);
+ ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격 즉시
if (!u.IsAlive) yield break;
}
else
@@ -413,6 +410,64 @@ namespace TKS
o.Path.Add(to);
}
+ /// 등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.
+ IEnumerator MoveStraight(Unit u, Vector2 to, ActionOutcome o)
+ {
+ yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null, false);
+ u.Position = to;
+ o.Path.Add(to);
+ }
+
+ /// 부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계.
+ void ImpactExchange(Unit a, Unit d, ActionOutcome o)
+ {
+ if (a.View != null) a.View.PlayAttack();
+ int dmg = Combat.Damage(cfg, a, d, false, rng, true);
+ d.TakeDamage(dmg);
+ o.Attacked.Add(d);
+ o.DamageDealt += dmg;
+ Fx.Popup(d.Position, dmg.ToString(), Color.white);
+ if (d.IsAlive && a.IsAlive)
+ {
+ int c = Combat.CounterDamage(cfg, d, a, rng);
+ if (d.View != null) d.View.PlayAttack();
+ a.TakeDamage(c);
+ o.CounterTaken += c;
+ Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
+ }
+ battle.NotifyChanged();
+ }
+
+ /// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio.
+ IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o)
+ {
+ bool struckDone = true;
+ if (struck != null && struck.IsAlive && remaining * cfg.collisionPushRatio >= 0.01f)
+ {
+ struckDone = false;
+ Fx.Run(Track(Push(struck, n, remaining * cfg.collisionPushRatio, striker, o), () => struckDone = true));
+ }
+ float self = remaining * cfg.collisionSelfBounceRatio;
+ if (self >= 0.01f && striker.IsAlive)
+ {
+ float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
+ if (back > 0.01f)
+ {
+ Vector2 end = battle.Board.ClampInside(striker.Position - n * back, striker.Radius);
+ yield return Fx.MoveAlong(striker.transform, striker.Position, end, cfg.moveSpeed, null);
+ striker.Position = end;
+ o.Path.Add(end);
+ }
+ }
+ while (!struckDone) yield return null;
+ }
+
+ static IEnumerator Track(IEnumerator inner, System.Action onDone)
+ {
+ yield return inner;
+ onDone();
+ }
+
/// 부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다.
IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o)
{
diff --git a/Assets/Scripts/Runtime/BalanceConfig.cs b/Assets/Scripts/Runtime/BalanceConfig.cs
index 72b536eb..e742766b 100644
--- a/Assets/Scripts/Runtime/BalanceConfig.cs
+++ b/Assets/Scripts/Runtime/BalanceConfig.cs
@@ -92,6 +92,8 @@ namespace TKS
[Header("충돌 (PD 2026-11-04 · #1080)")]
[Tooltip("보병 알까기: 부딪힌 유닛(아군·적군)이 밀려나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 · 창병은 비율 없이 남은 거리만큼 밀며 함께 전진")]
public float collisionPushRatio = 1f;
+ [Tooltip("보병 알까기: 부딪힌 보병 자신이 반대 방향으로 튕겨나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 (PD #1081 「자신도 튕겨나야」)")]
+ public float collisionSelfBounceRatio = 0.5f;
[Header("표시")]
[Tooltip("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")]
diff --git a/Assets/Scripts/Runtime/Fx.cs b/Assets/Scripts/Runtime/Fx.cs
index 30d3fcae..954d1c21 100644
--- a/Assets/Scripts/Runtime/Fx.cs
+++ b/Assets/Scripts/Runtime/Fx.cs
@@ -25,8 +25,8 @@ namespace TKS
}
}
- /// from → to 를 감속하며 이동. onDistance 에는 지금까지 지나온 거리를 넘긴다.
- public static IEnumerator MoveAlong(Transform tr, Vector2 from, Vector2 to, float speed, Action onDistance)
+ /// from → to 이동(기본 = 감속 · easeOut false = 등속 → 부딪히는 순간까지 속도를 유지). onDistance 에는 지금까지 지나온 거리를 넘긴다.
+ public static IEnumerator MoveAlong(Transform tr, Vector2 from, Vector2 to, float speed, Action onDistance, bool easeOut = true)
{
float total = Vector2.Distance(from, to);
if (total < 1e-4f) yield break;
@@ -36,7 +36,7 @@ namespace TKS
{
el += Time.deltaTime;
float k = Mathf.Clamp01(el / duration);
- float e = 1f - (1f - k) * (1f - k);
+ float e = easeOut ? 1f - (1f - k) * (1f - k) : k;
var p = Vector2.Lerp(from, to, e);
tr.position = new Vector3(p.x, p.y, 0f);
if (onDistance != null) onDistance(e * total);
diff --git a/Assets/Scripts/Runtime/UnitView.cs b/Assets/Scripts/Runtime/UnitView.cs
index f1929411..bace417a 100644
--- a/Assets/Scripts/Runtime/UnitView.cs
+++ b/Assets/Scripts/Runtime/UnitView.cs
@@ -85,8 +85,10 @@ namespace TKS
highlightRing = ProceduralSprites.NewSprite(transform, "HighlightRing", ProceduralSprites.Ring, new Color(1f, 0.5f, 0.15f), d + 0.28f, 1);
highlightRing.enabled = false;
+ // HP 바 = 도형이면 원 위 · 아트면 머리 위(발 = −0.55 R · 세로 ArtHeight) — 얼굴을 가리지 않게(PD #1081 첨부 캡처)
+ float topY = anim != null ? -u.Radius * 0.55f + u.ArtHeight + 0.12f : u.Radius + 0.22f;
hpBg = ProceduralSprites.NewSprite(transform, "HpBg", ProceduralSprites.Square, new Color(0.08f, 0.08f, 0.08f, 0.9f), 1f, 2);
- hpBg.transform.localPosition = new Vector3(0f, u.Radius + 0.22f, 0f);
+ hpBg.transform.localPosition = new Vector3(0f, topY, 0f);
hpBg.transform.localScale = new Vector3(HpBarWidth, 0.12f, 1f);
hpFill = ProceduralSprites.NewSprite(transform, "HpFill", ProceduralSprites.Square, new Color(0.35f, 0.9f, 0.35f), 1f, 3);
hpFill.transform.localPosition = hpBg.transform.localPosition;
@@ -97,7 +99,7 @@ namespace TKS
nameLabel = TextUtil.CreateWorldText(transform, anim != null ? u.DisplayName + "·" + UnitTypeInfo.Mark(u.Type) : u.DisplayName, 0.26f, new Color(0.95f, 0.95f, 0.9f), 4);
nameLabel.transform.localPosition = new Vector3(0f, -u.Radius - 0.22f, 0f);
badge = TextUtil.CreateWorldText(transform, "추가 이동", 0.22f, new Color(1f, 0.95f, 0.4f), 5);
- badge.transform.localPosition = new Vector3(0f, u.Radius + 0.46f, 0f);
+ badge.transform.localPosition = new Vector3(0f, topY + 0.24f, 0f);
badge.gameObject.SetActive(false);
Refresh();
diff --git a/Assets/Tests/PlayMode/BattleFlowTests.cs b/Assets/Tests/PlayMode/BattleFlowTests.cs
index 2e93fcf8..41d391f6 100644
--- a/Assets/Tests/PlayMode/BattleFlowTests.cs
+++ b/Assets/Tests/PlayMode/BattleFlowTests.cs
@@ -90,7 +90,8 @@ namespace TKS.Tests
Assert.Contains(foe, o.Attacked);
Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적을 공격");
Assert.IsTrue(o.BonusMove, "최종 목표점 명중 → 추가 이동");
- Assert.AreEqual(contactY, inf.Position.y, 0.1f, "보병은 부딪힌 지점에서 멈춘다(알까기)");
+ float remaining = CastContact(inf, foe); // 당긴 거리 3 − 접촉까지 거리 = 남은 힘
+ Assert.AreEqual(contactY - remaining * cfg.collisionSelfBounceRatio, inf.Position.y, 0.1f, "보병 자신도 반대 방향으로 튕긴다(PD #1081 · 남은 힘 × 비율)");
Assert.Contains(foe, o.Pushed, "부딪힌 유닛은 밀려난다");
Assert.Greater(foe.Position.y, 0.3f, "적이 남은 힘만큼 밀려남");
Assert.Less(inf.Hp, inf.MaxHp, "근접 공격은 반격 피해를 받는다");
@@ -134,6 +135,7 @@ namespace TKS.Tests
Assert.LessOrEqual(Gap(b, foe), Combat.ContactTolerance + 0.05f, "밀려서 적과 닿음");
Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군 B 가 적을 공격");
Assert.AreEqual(a.MaxHp, a.Hp, "A 는 피해 없음");
+ Assert.Less(a.Position.x, -pushX - 0.2f, "A 도 반대로 튕김(아군과 부딪혀도 알까기)");
Assert.IsFalse(b.ActedThisTurn, "B 의 공격은 자기 행동을 소모하지 않음");
Assert.AreEqual(1, o.Notes.Count);
diff --git a/CLAUDE.md b/CLAUDE.md
index 5df6470f..ff527842 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,7 +47,7 @@ Unity.exe -projectPath -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
- 콜라이더 질의 전 `Physics2D.SyncTransforms()` · 유닛 제거는 `SetActive(false)` 후 `Destroy` · 자식 렌더러가 있는 표시물은 GameObject 통째로 끈다.
- **궁병 규칙(PD 2026-11-04 · #1080)**: 사거리 = `slingMaxDistance`(얇은 원). 조준 방향 사거리 안의 적(가장자리 간격 기준) = **제자리 사격** · 사거리 밖 ~ ×(1 + `archerRangeRatio`)(붉은 도넛) 안의 적 = 들어올 만큼만(최대 `ArcherMoveAllow` = 슬링 × 비율) 다가가 사격 · 더 멀면 **목표점까지 이동만**(#1079). 반격 없음 · 목표점 명중 크리. **근접 전투**(돌진·반격·관통·견제·밀려 닿은 공격)에서 궁병은 근접 유닛 상대 주는 피해 × `archerMeleeDealtRatio`(0.5) · 받는 피해 × `archerMeleeTakenRatio`(1.5) — `Combat.Damage(…, melee)` · 원거리 사격은 패널티 없음.
-- **충돌 규칙(PD 2026-11-04 · #1080)** — `ActionResolver`: 보병 = 경로 첫 유닛(아군·적군)에 부딪히면 **알까기**(자신은 접촉점에 멈춤 · 상대는 접촉 방향으로 남은 거리 × `collisionPushRatio` 만큼 밀림 · 적이면 공격+반격 뒤 밀림) · 기병 = 유닛 무시 관통(적 피해 · 아군은 지나칠 거리가 안 나오면 **그 아군 직전 정지** `StopBeforeUnclearedAllies`) · 창병 = 첫 유닛을 **밀며 함께 전진**(접촉 간격 유지 · 앞 유닛이 막히면 둘 다 정지 · 적이면 피해 · 아군은 밀기만) · **규칙 ⑤** = 밀린 같은 진영 유닛이 상대와 닿으면(`Combat.TouchingOpponent` · 간격 ≤ 0.12) 그 자리에서 근접 공격 1회(조작마다 · 자기 행동 소모 없음 · `ActionOutcome.Notes`). 적 AI 도 같은 코드(대칭). `UnitStats.attackRange` 는 창병 견제 반경만.
+- **충돌 규칙(PD 2026-11-04 · #1080)** — `ActionResolver`: 보병 = 경로 첫 유닛(아군·적군)에 부딪히면 **알까기**(등속 접근 → 부딪힌 순간 피해·반격을 즉시 교환(`ImpactExchange`) → **양쪽이 동시에 튕김**: 상대는 접촉 방향으로 남은 거리 × `collisionPushRatio` · 자신은 반대 방향으로 남은 거리 × `collisionSelfBounceRatio` · PD #1081) · 기병 = 유닛 무시 관통(적 피해 · 아군은 지나칠 거리가 안 나오면 **그 아군 직전 정지** `StopBeforeUnclearedAllies`) · 창병 = 첫 유닛을 **밀며 함께 전진**(접촉 간격 유지 · 앞 유닛이 막히면 둘 다 정지 · 적이면 피해 · 아군은 밀기만) · **규칙 ⑤** = 밀린 같은 진영 유닛이 상대와 닿으면(`Combat.TouchingOpponent` · 간격 ≤ 0.12) 그 자리에서 근접 공격 1회(조작마다 · 자기 행동 소모 없음 · `ActionOutcome.Notes`). 적 AI 도 같은 코드(대칭). `UnitStats.attackRange` 는 창병 견제 반경만.
- **사거리 표시(PD 2026-11-04)**: 유닛 탭(아군·적군) → `RangeIndicator`(슬링샷 최대 거리 = 얇은 반투명 원 · 궁병 = 슬링샷 범위 ~ ×(1+비율) 채워진 붉은 도넛 · 범위 안 적이 없으면 회색).
- 보고는 실측·추정·미확인을 분리한다(C23 · C5). 커밋은 의도 변경 파일만.
diff --git a/README.md b/README.md
index 89ec0ee2..a15599b2 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Unity 6000.3.23f1 · 2D · 도형 플레이스홀더(아트 리소스는 별도
| 병종 | 경로 위 적 | 최종 목표점 명중 시 |
|---|---|---|
-| 보병 | 첫 유닛(아군·적군)에 부딪히면 알까기처럼 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) | 추가 이동 1회(이동만) |
+| 보병 | 첫 유닛(아군·적군)에 부딪히면 알까기처럼 양쪽이 즉시 튕김(상대 = 남은 힘 · 자신 = 반대로 절반) · 적이면 부딪힌 순간 공격(반격 받음) | 추가 이동 1회(이동만) |
| 기병 | 유닛을 무시하고 관통 · 경로 위 적은 모두 피해 · 아군은 지나칠 거리가 안 나오면 직전에 멈춤 | 제자리로 복귀 |
| 창병 | 첫 유닛을 튕기지 않고 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군은 밀기만 | 대기 — 적군 턴에 반경 안에서 움직이는 적에게 피해 |
| 궁병 | 사거리(= 슬링샷 범위) 안의 적은 제자리 사격 · 사거리 밖 +20 % 안의 적은 들어올 만큼만 다가가 사격(반격 없음) · 더 멀면 목표점까지 이동 | 무조건 크리티컬 |