궁병 — 조준 방향에 적이 없으면 목표점까지 이동(PD 2026-11-04 2차 지시 · 「행동 취소」 폐기) · 에디터 그래픽 API D3D11 우선(oCam 훅 D3D12 크래시 우회) · 테스트 17/17

- ActionResolver.ArcherRoutine: 복도(당긴 거리 + 사거리) 안에 적 없음 → FreeTravel 로 목표점까지 이동 · MovedOnly · ActionOutcome.Cancelled 삭제
- SlingshotInput: archerBlocked(놓기 취소 · 회색 목표 링 · HUD 안내 문구) 삭제 · BattleManager.Describe 「사거리 안에 적이 없어 대기」 삭제 · AI 적 궁병도 전진
- 테스트: Archer_CannotAct_… → Archer_MovesToTarget_WhenNoEnemyWithinSlingPlusRatio (사본 배치 error CS 0 · 17/17)
- ProjectSettings: WindowsStandaloneSupport m_APIs D3D11 → D3D12 (PD 에디터 15:04 크래시 2회 = oCam HookSurfaceDLL → D3D12SwapChain::Present · 사본 GUI 실측 Direct3D 11.0 · oCam 켜진 채 자동 플레이 1턴 정상)
- CLAUDE.md(궁병 규칙 · 크래시 진단 절차) · README 병종 표 갱신

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-04 15:16:15 +09:00
parent bc8e571eea
commit 645f8646cf
7 changed files with 29 additions and 54 deletions

View File

@ -12,7 +12,6 @@ namespace TKS
public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제) public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제)
public bool Crit; // 궁병: 최종 목표점 명중 → 크리티컬 public bool Crit; // 궁병: 최종 목표점 명중 → 크리티컬
public bool MovedOnly; // 공격 없이 이동만 public bool MovedOnly; // 공격 없이 이동만
public bool Cancelled; // 궁병: 조준 방향 슬링샷 범위 + 사거리 안에 적이 없어 행동하지 않음
public int DamageDealt; public int DamageDealt;
public int CounterTaken; public int CounterTaken;
public List<Unit> Attacked = new List<Unit>(); public List<Unit> Attacked = new List<Unit>();
@ -25,8 +24,9 @@ namespace TKS
/// ① 보병: 경로 위 첫 적까지 이동 후 공격 · 목표점 명중 시 추가 이동 1회 /// ① 보병: 경로 위 첫 적까지 이동 후 공격 · 목표점 명중 시 추가 이동 1회
/// ② 기병: 경로 위 적을 모두 관통 피해 · 목표점까지 이동 · 목표점 명중 시 제자리 복귀 /// ② 기병: 경로 위 적을 모두 관통 피해 · 목표점까지 이동 · 목표점 명중 시 제자리 복귀
/// ③ 창병: 보병과 동일 · 목표점 명중 시 대기 → 반경 안에서 움직이는 적에게 피해 /// ③ 창병: 보병과 동일 · 목표점 명중 시 대기 → 반경 안에서 움직이는 적에게 피해
/// ④ 궁병: 조준 방향 슬링샷 거리 + 사거리(슬링샷 최대 × archerRangeRatio) 안에 적이 있을 때만 행동 — /// ④ 궁병: 조준 방향 슬링샷 거리 + 사거리(슬링샷 최대 × archerRangeRatio) 안에 적이 있으면
/// 적 가장자리가 사거리에 들어올 때까지만 이동 후 사격(반격 없음) · 목표점 명중 시 크리티컬 · 적이 없으면 취소(PD 2026-11-04) /// 적 가장자리가 사거리에 들어올 때까지만 이동 후 사격(반격 없음) · 목표점 명중 시 크리티컬 ·
/// 적이 없으면 목표점까지 이동만(PD 2026-11-04 2차 지시 · 1차의 「행동 취소」 폐기)
/// </summary> /// </summary>
public class ActionResolver public class ActionResolver
{ {
@ -251,23 +251,18 @@ namespace TKS
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{ {
if (moveOnly) // 조준 방향으로 슬링샷 거리 + 사거리 안의 첫 적. hitDist = 그 적 가장자리에 닿기까지의 이동 거리.
float hitDist = 0f;
Unit first = moveOnly ? null : Combat.FirstEnemyOnPath(u, origin, dir, dist + u.AttackRange, out hitDist);
if (first == null)
{ {
// 적이 없으면 목표점까지 이동(PD 2026-11-04 2차: 「슬링샷 범위 내 적이 없을 경우 해당 위치까지 이동」)
float free = Combat.FreeTravel(u, origin, dir, dist, true); float free = Combat.FreeTravel(u, origin, dir, dist, true);
yield return Move(u, origin + dir * free, o); yield return Move(u, origin + dir * free, o);
o.MovedOnly = true; o.MovedOnly = true;
yield break; yield break;
} }
// 조준 방향으로 슬링샷 거리 + 사거리 안의 첫 적. hitDist = 그 적 가장자리에 닿기까지의 이동 거리.
float hitDist;
Unit first = Combat.FirstEnemyOnPath(u, origin, dir, dist + u.AttackRange, out hitDist);
if (first == null)
{
o.Cancelled = true; // PD 2026-11-04: 슬링샷 범위 + 비율 안에 적이 있을 때만 행동 가능
yield break;
}
// 적 가장자리까지 남은 간격이 사거리 안에 들어올 때까지만 이동(슬링샷 거리 상한) // 적 가장자리까지 남은 간격이 사거리 안에 들어올 때까지만 이동(슬링샷 거리 상한)
float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, dist); float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, dist);
float freeMove = Combat.FreeTravel(u, origin, dir, need, true); float freeMove = Combat.FreeTravel(u, origin, dir, need, true);

View File

@ -383,7 +383,6 @@ namespace TKS
static string Describe(Unit u, ActionOutcome o, bool moveOnly) static string Describe(Unit u, ActionOutcome o, bool moveOnly)
{ {
string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")"; string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")";
if (o.Cancelled) return who + " 사거리 안에 적이 없어 대기";
if (o.Attacked.Count == 0) if (o.Attacked.Count == 0)
{ {
return who + (moveOnly ? " 추가 이동" : " 이동"); return who + (moveOnly ? " 추가 이동" : " 이동");

View File

@ -7,13 +7,12 @@ namespace TKS
/// 슬링샷 조작: 아군 유닛을 잡고(누름) 뒤로 당긴 뒤(드래그) 놓으면(드롭) 반대 방향으로 이동·공격. /// 슬링샷 조작: 아군 유닛을 잡고(누름) 뒤로 당긴 뒤(드래그) 놓으면(드롭) 반대 방향으로 이동·공격.
/// 유닛 탭(아군·적군) = 그 유닛의 사거리 표시(RangeIndicator) · 빈 곳 드래그 = 전장 스크롤 · 휠 = 줌 · 미니맵 탭 = 그 지점으로 이동. /// 유닛 탭(아군·적군) = 그 유닛의 사거리 표시(RangeIndicator) · 빈 곳 드래그 = 전장 스크롤 · 휠 = 줌 · 미니맵 탭 = 그 지점으로 이동.
/// 버튼 없음. 결과 화면에서는 탭으로 계속. 추가 이동만 남았을 때 빈 곳 탭 = 건너뛰기. /// 버튼 없음. 결과 화면에서는 탭으로 계속. 추가 이동만 남았을 때 빈 곳 탭 = 건너뛰기.
/// 궁병(PD 2026-11-04): 조준 방향 슬링샷 거리 + 사거리 안에 적이 없으면 놓아도 행동하지 않는다(선택 유지 · 안내 문구). /// 궁병도 다른 병종과 같이 놓으면 바로 행동한다(조준 방향에 적이 없으면 목표점까지 이동 · PD 2026-11-04 2차 지시).
/// </summary> /// </summary>
public class SlingshotInput : MonoBehaviour public class SlingshotInput : MonoBehaviour
{ {
static readonly Color TargetFreeColor = Color.white; static readonly Color TargetFreeColor = Color.white;
static readonly Color TargetHitColor = new Color(1f, 0.6f, 0.1f); static readonly Color TargetHitColor = new Color(1f, 0.6f, 0.1f);
static readonly Color TargetBlockedColor = new Color(0.65f, 0.65f, 0.65f, 0.7f);
BattleManager battle; BattleManager battle;
Camera cam; Camera cam;
@ -26,7 +25,6 @@ namespace TKS
bool panning; bool panning;
bool emptyPress; bool emptyPress;
bool previewValid; bool previewValid;
bool archerBlocked;
Vector2 lastScreen; Vector2 lastScreen;
float panPixels; float panPixels;
Vector2 currentTarget; Vector2 currentTarget;
@ -124,7 +122,6 @@ namespace TKS
panning = false; panning = false;
emptyPress = false; emptyPress = false;
previewValid = false; previewValid = false;
archerBlocked = false;
Vector2 mapWorld; Vector2 mapWorld;
if (hud != null && camCtrl != null && hud.TryMinimapTap(screen, out mapWorld)) if (hud != null && camCtrl != null && hud.TryMinimapTap(screen, out mapWorld))
@ -170,35 +167,17 @@ namespace TKS
{ {
HidePreview(); HidePreview();
previewValid = false; previewValid = false;
archerBlocked = false;
return; return;
} }
if (mag > selected.SlingMaxDistance) launch = launch / mag * selected.SlingMaxDistance; if (mag > selected.SlingMaxDistance) launch = launch / mag * selected.SlingMaxDistance;
currentTarget = battle.Board.ClampInside(selected.Position + launch, selected.Radius); currentTarget = battle.Board.ClampInside(selected.Position + launch, selected.Radius);
previewValid = true; previewValid = true;
int targets = ShowPreview(selected, currentTarget, w); ShowPreview(selected, currentTarget, w);
archerBlocked = selected.Type == UnitType.Archer && !selected.BonusMoveAvailable && targets == 0;
if (archerBlocked)
{
targetRing.color = TargetBlockedColor;
targetRing.transform.localScale = new Vector3(0.7f, 0.7f, 1f);
}
} }
void OnRelease(Vector2 screen) void OnRelease(Vector2 screen)
{ {
if (dragging && selected != null && previewValid && battle.CanAct(selected)) if (dragging && selected != null && previewValid && battle.CanAct(selected))
{
if (archerBlocked)
{
// 궁병: 조준 방향 슬링샷 범위 + 비율 안에 적 없음 → 행동 취소(선택·사거리 표시 유지)
HidePreview();
if (selected.View != null) selected.View.SetAiming(false);
int pct = Mathf.RoundToInt(battle.Config.archerRangeRatio * 100f);
battle.LastMessage = selected.DisplayName + "(궁병) — 슬링샷 범위 +" + pct + " % 안에 적이 없어 행동할 수 없습니다";
battle.NotifyChanged();
}
else
{ {
var t = currentTarget; var t = currentTarget;
var u = selected; var u = selected;
@ -207,7 +186,6 @@ namespace TKS
Select(null); Select(null);
battle.RequestPlayerAction(u, t); battle.RequestPlayerAction(u, t);
} }
}
else if (emptyPress && panPixels <= TapPixels) else if (emptyPress && panPixels <= TapPixels)
{ {
battle.OnEmptyTap(); battle.OnEmptyTap();
@ -221,7 +199,6 @@ namespace TKS
panning = false; panning = false;
emptyPress = false; emptyPress = false;
previewValid = false; previewValid = false;
archerBlocked = false;
} }
/// <summary>유닛 선택(아군·적군 · null = 해제): 선택 링 + HUD 정보 + 사거리 표시(궁병은 도넛 · 범위 안 적 유무로 색).</summary> /// <summary>유닛 선택(아군·적군 · null = 해제): 선택 링 + HUD 정보 + 사거리 표시(궁병은 도넛 · 범위 안 적 유무로 색).</summary>

View File

@ -138,7 +138,6 @@ namespace TKS.Tests
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -0.2f), "적보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -0.2f), "적보병");
var o = new ActionOutcome(); var o = new ActionOutcome();
yield return bm.ResolveForTest(arc, new Vector2(0f, -1f), false, o); yield return bm.ResolveForTest(arc, new Vector2(0f, -1f), false, o);
Assert.IsFalse(o.Cancelled);
Assert.Contains(foe, o.Attacked, "조준 방향의 적을 사거리까지 이동 후 사격"); Assert.Contains(foe, o.Attacked, "조준 방향의 적을 사거리까지 이동 후 사격");
Assert.Less(foe.Hp, foe.MaxHp); Assert.Less(foe.Hp, foe.MaxHp);
Assert.AreEqual(arc.MaxHp, arc.Hp, "궁병은 반격을 받지 않는다"); Assert.AreEqual(arc.MaxHp, arc.Hp, "궁병은 반격을 받지 않는다");
@ -149,19 +148,20 @@ namespace TKS.Tests
} }
[UnityTest] [UnityTest]
public IEnumerator Archer_CannotAct_WhenNoEnemyWithinSlingPlusRatio() public IEnumerator Archer_MovesToTarget_WhenNoEnemyWithinSlingPlusRatio()
{ {
yield return Fresh(); yield return Fresh();
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
// 적 가장자리까지 6.0 − 반지름 합 ≈ 5.2 > 4.0 + 0.8 → 행동 불가(이동도 없음) // 적 가장자리까지 6.0 − 반지름 합 ≈ 5.2 > 슬링샷 + 사거리 → 공격 없이 목표점까지 이동(PD 2026-11-04 2차 지시)
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적보병");
Assert.IsFalse(bm.AnyEnemyWithinArcherReach(arc), "공격 가능 범위 안에 적 없음(도넛 회색)"); Assert.IsFalse(bm.AnyEnemyWithinArcherReach(arc), "공격 가능 범위 안에 적 없음(도넛 회색)");
var o = new ActionOutcome(); var o = new ActionOutcome();
yield return bm.ResolveForTest(arc, new Vector2(0f, -1f), false, o); var target = new Vector2(0f, -1f);
Assert.IsTrue(o.Cancelled, "슬링샷 범위 + 비율 안에 적이 없으면 궁병은 행동하지 않는다"); yield return bm.ResolveForTest(arc, target, false, o);
Assert.IsTrue(o.MovedOnly, "슬링샷 범위 + 비율 안에 적이 없으면 궁병은 목표점까지 이동만 한다");
Assert.AreEqual(0, o.Attacked.Count); Assert.AreEqual(0, o.Attacked.Count);
Assert.AreEqual(foe.MaxHp, foe.Hp); Assert.AreEqual(foe.MaxHp, foe.Hp);
Assert.Less(Vector2.Distance(arc.Position, new Vector2(0f, -5f)), 0.01f, "이동도 하지 않는다"); Assert.Less(Vector2.Distance(arc.Position, target), 0.05f, "목표 지점까지 이동");
var near = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -0.4f), "가까운적"); var near = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -0.4f), "가까운적");
Assert.IsTrue(bm.AnyEnemyWithinArcherReach(arc), "범위 안에 적이 생기면 도넛 붉게"); Assert.IsTrue(bm.AnyEnemyWithinArcherReach(arc), "범위 안에 적이 생기면 도넛 붉게");

View File

@ -39,13 +39,14 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- `Unity.exe` = `C:\Program Files\Unity\Hub\Editor\6000.3.23f1\Editor\Unity.exe`. 에디터가 같은 프로젝트를 열고 있으면 배치 실행은 못 한다(`Temp/UnityLockfile`) — 먼저 `unity status` 로 확인. - `Unity.exe` = `C:\Program Files\Unity\Hub\Editor\6000.3.23f1\Editor\Unity.exe`. 에디터가 같은 프로젝트를 열고 있으면 배치 실행은 못 한다(`Temp/UnityLockfile`) — 먼저 `unity status` 로 확인.
- 배치 로그 판정 = `error CS` 0 · 테스트 XML `failed="0"`. - 배치 로그 판정 = `error CS` 0 · 테스트 XML `failed="0"`.
- **에디터가 열리면서 크래시하면 oCam(화면 녹화)을 의심**: `%LOCALAPPDATA%\Unity\Editor\Editor.log` 꼬리의 스택에 `HookSurfaceDLL` → `D3D12SwapChain::Present` 가 있으면 oCam 의 D3D12 훅이 원인(2026-11-04 15:04 × 2 실측). 이 프로젝트는 그래서 Windows Graphics API 를 **D3D11 우선**으로 고정했다(`ProjectSettings.asset` `m_BuildTargetGraphicsAPIs` · 에디터도 이 설정을 따름). 그래도 크래시하면 `-force-d3d11` 인수 또는 oCam 종료. 배치 `-nographics` 는 영향 없음.
## 코드 규칙 ## 코드 규칙
- 수치·영웅·스테이지 SOT = `Assets/Resources/BalanceConfig.asset`(Inspector 조정 · 최초 시드만 `Assets/Scripts/Editor/BalanceDefaults.cs`). 코드·테스트에 수치를 직접 적지 않는다(C45). - 수치·영웅·스테이지 SOT = `Assets/Resources/BalanceConfig.asset`(Inspector 조정 · 최초 시드만 `Assets/Scripts/Editor/BalanceDefaults.cs`). 코드·테스트에 수치를 직접 적지 않는다(C45).
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 14종 이상 전부 통과 확인. - 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 14종 이상 전부 통과 확인.
- 콜라이더 질의 전 `Physics2D.SyncTransforms()` · 유닛 제거는 `SetActive(false)` 후 `Destroy` · 자식 렌더러가 있는 표시물은 GameObject 통째로 끈다. - 콜라이더 질의 전 `Physics2D.SyncTransforms()` · 유닛 제거는 `SetActive(false)` 후 `Destroy` · 자식 렌더러가 있는 표시물은 GameObject 통째로 끈다.
- **궁병 규칙(PD 2026-11-04)**: 사거리 = `slingMaxDistance × archerRangeRatio`(자산 · 기본 0.2 = 슬링샷 범위 +20 %). 조준 방향 「슬링샷 거리 + 사거리」 안에 적이 없으면 행동 불가(`SlingshotInput` 이 놓기 자체를 취소 · AI 는 `ActionOutcome.Cancelled` 로 대기) · 적 **가장자리**가 사거리에 들어올 때까지만 이동 후 사격. `UnitStats.attackRange` 는 창병 견제 반경만. - **궁병 규칙(PD 2026-11-04)**: 사거리 = `slingMaxDistance × archerRangeRatio`(자산 · 기본 0.2 = 슬링샷 범위 +20 %). 조준 방향 「슬링샷 거리 + 사거리」 안에 적이 있으면 적 **가장자리**가 사거리에 들어올 때까지만 이동 후 사격 · 적이 없으면 **목표점까지 이동만**(PD 2026-11-04 2차 지시 · 1차의 「행동 취소」 폐기 · AI 적 궁병도 같음). `UnitStats.attackRange` 는 창병 견제 반경만.
- **사거리 표시(PD 2026-11-04)**: 유닛 탭(아군·적군) → `RangeIndicator`(슬링샷 최대 거리 = 얇은 반투명 원 · 궁병 = 슬링샷 범위 ~ ×(1+비율) 채워진 붉은 도넛 · 범위 안 적이 없으면 회색). - **사거리 표시(PD 2026-11-04)**: 유닛 탭(아군·적군) → `RangeIndicator`(슬링샷 최대 거리 = 얇은 반투명 원 · 궁병 = 슬링샷 범위 ~ ×(1+비율) 채워진 붉은 도넛 · 범위 안 적이 없으면 회색).
- 보고는 실측·추정·미확인을 분리한다(C23 · C5). 커밋은 의도 변경 파일만. - 보고는 실측·추정·미확인을 분리한다(C23 · C5). 커밋은 의도 변경 파일만.

View File

@ -395,7 +395,10 @@ PlayerSettings:
m_BuildTargetShaderSettings: [] m_BuildTargetShaderSettings: []
m_BuildTargetGraphicsJobs: [] m_BuildTargetGraphicsJobs: []
m_BuildTargetGraphicsJobMode: [] m_BuildTargetGraphicsJobMode: []
m_BuildTargetGraphicsAPIs: [] m_BuildTargetGraphicsAPIs:
- m_BuildTarget: WindowsStandaloneSupport
m_APIs: 0200000012000000
m_Automatic: 0
m_BuildTargetVRSettings: [] m_BuildTargetVRSettings: []
m_DefaultShaderChunkSizeInMB: 16 m_DefaultShaderChunkSizeInMB: 16
m_DefaultShaderChunkCount: 0 m_DefaultShaderChunkCount: 0

View File

@ -25,7 +25,7 @@ Unity 6000.3.23f1 · 2D · 도형 플레이스홀더(아트 리소스는 별도
| 보병 | 첫 적까지 이동 후 공격(반격 받음) | 추가 이동 1회(이동만) | | 보병 | 첫 적까지 이동 후 공격(반격 받음) | 추가 이동 1회(이동만) |
| 기병 | 경로 위 모든 적 관통 피해 · 목표점까지 이동 | 제자리로 복귀 | | 기병 | 경로 위 모든 적 관통 피해 · 목표점까지 이동 | 제자리로 복귀 |
| 창병 | 보병과 동일 | 대기 — 적군 턴에 반경 안에서 움직이는 적에게 피해 | | 창병 | 보병과 동일 | 대기 — 적군 턴에 반경 안에서 움직이는 적에게 피해 |
| 궁병 | 목표 방향의 적을 향해 사거리까지 이동 후 사격(반격 없음) | 무조건 크리티컬 | | 궁병 | 목표 방향 「슬링샷 거리 + 사거리」 안의 적을 향해 사거리까지 이동 후 사격(반격 없음) · 적이 없으면 목표점까지 이동 | 무조건 크리티컬 |
상성: 보병 > 창병 > 기병 > 보병. 궁병은 모두에게 약함(받는 피해 1.5배). 상성: 보병 > 창병 > 기병 > 보병. 궁병은 모두에게 약함(받는 피해 1.5배).