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 % 안의 적은 들어올 만큼만 다가가 사격(반격 없음) · 더 멀면 목표점까지 이동 | 무조건 크리티컬 |