충돌 연출 #1081 — 보병 알까기 양쪽 동시 튕김(자신 = 반대 방향 × collisionSelfBounceRatio 0.5) · 부딪힌 순간 즉시 피해 교환·밀기(등속 접근 · 돌진 연출 대기 폐기) · 체력바 머리 위 · 전장 배경 2판(VSClone 일러 풍 · 삼국지 전장) · 테스트 22/22
- ActionResolver: MoveStraight(등속) · ImpactExchange(접촉 순간 공격+반격 즉시) · Bounce(상대 Push + 자신 되튕김 동시 · Track) · Infantry/Spear 순서 재배치 - Fx.MoveAlong easeOut 인자 · BalanceConfig/Defaults collisionSelfBounceRatio(자산 재시드) · UnitView HP 바·추가 이동 표시 = 아트 머리 위 - Resources/Board/battlefield_01.png = 원화 세션 2판(md5 1ab19b9b… · 1판은 C:\ArtRnD\samguk_game\bg\_old) - 테스트: 보병 자신 되튕김 · 아군 밀기 시 A 되튕김 단언 · CLAUDE.md · README Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
6cdab1fc11
commit
365bccc2bf
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 6.3 MiB After Width: | Height: | Size: 8.1 MiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다.</summary>
|
||||
IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")]
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ namespace TKS
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>from → to 를 감속하며 이동. onDistance 에는 지금까지 지나온 거리를 넘긴다.</summary>
|
||||
public static IEnumerator MoveAlong(Transform tr, Vector2 from, Vector2 to, float speed, Action<float> onDistance)
|
||||
/// <summary>from → to 이동(기본 = 감속 · easeOut false = 등속 → 부딪히는 순간까지 속도를 유지). onDistance 에는 지금까지 지나온 거리를 넘긴다.</summary>
|
||||
public static IEnumerator MoveAlong(Transform tr, Vector2 from, Vector2 to, float speed, Action<float> 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Unity.exe -projectPath <proj> -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). 커밋은 의도 변경 파일만.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Unity 6000.3.23f1 · 2D · 도형 플레이스홀더(아트 리소스는 별도
|
|||
|
||||
| 병종 | 경로 위 적 | 최종 목표점 명중 시 |
|
||||
|---|---|---|
|
||||
| 보병 | 첫 유닛(아군·적군)에 부딪히면 알까기처럼 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) | 추가 이동 1회(이동만) |
|
||||
| 보병 | 첫 유닛(아군·적군)에 부딪히면 알까기처럼 양쪽이 즉시 튕김(상대 = 남은 힘 · 자신 = 반대로 절반) · 적이면 부딪힌 순간 공격(반격 받음) | 추가 이동 1회(이동만) |
|
||||
| 기병 | 유닛을 무시하고 관통 · 경로 위 적은 모두 피해 · 아군은 지나칠 거리가 안 나오면 직전에 멈춤 | 제자리로 복귀 |
|
||||
| 창병 | 첫 유닛을 튕기지 않고 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군은 밀기만 | 대기 — 적군 턴에 반경 안에서 움직이는 적에게 피해 |
|
||||
| 궁병 | 사거리(= 슬링샷 범위) 안의 적은 제자리 사격 · 사거리 밖 +20 % 안의 적은 들어올 만큼만 다가가 사격(반격 없음) · 더 멀면 목표점까지 이동 | 무조건 크리티컬 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue