diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs index 5c528c6..ccdf4ac 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs @@ -58,11 +58,11 @@ public static IReadOnlyList ChecklistFor(SpellFamily family) { return family switch { - SpellFamily.Fire => new[] { "꼭짓점 3개가 보이게 그리기", "마지막 점을 시작점 근처로 닫기", "삼각형을 위쪽으로 세우기" }, - SpellFamily.Water => new[] { "한 획으로 둥글게 이어 그리기", "끝점을 시작점 근처로 닫기", "찌그러짐보다 큰 원형 흐름 유지" }, - SpellFamily.Wind => new[] { "짧은 선 3개를 따로 그리기", "세 선을 비슷한 방향으로 놓기", "너무 닫힌 도형처럼 만들지 않기" }, - SpellFamily.Earth => new[] { "사다리꼴 네 모서리 만들기", "아래 변을 더 넓게 그리기", "끝점을 닫아 안정감 만들기" }, - SpellFamily.Life => new[] { "아래 줄기에서 위로 올라가기", "상단에서 좌우 가지 만들기", "닫힌 도형보다 열린 Y 형태 유지" }, + SpellFamily.Fire => new[] { "세 꼭짓점을 크게 찍기", "마지막 선을 처음 아래 꼭짓점으로 돌려 닫기", "삼각형을 기울이지 말고 세우기" }, + SpellFamily.Water => new[] { "한 획으로 둥글게 돌기", "끝점을 시작점 바로 옆에 놓기", "찌그러진 타원보다 고른 원 유지" }, + SpellFamily.Wind => new[] { "위, 가운데, 아래 3획만 남기기", "세 선을 같은 기울기로 맞추기", "세 줄 사이 간격을 비슷하게 벌리기" }, + SpellFamily.Earth => new[] { "윗변은 좁고 아랫변은 넓게 잡기", "네 모서리를 분명하게 꺾기", "마지막 선으로 사다리꼴을 닫기" }, + SpellFamily.Life => new[] { "가운데 줄기를 먼저 세우기", "같은 분기점에서 좌우 가지 뻗기", "원처럼 닫지 말고 끝을 열어 두기" }, _ => Array.Empty() }; } @@ -88,7 +88,7 @@ private static string BodyFor(SpellFamily family, AssistLevel level, SpellResult if (level == AssistLevel.ReasonHint) { - return result == null ? ActionHintFor(family) : result.nextHint; + return string.IsNullOrWhiteSpace(result?.nextHint) ? ActionHintFor(family) : result.nextHint; } if (level == AssistLevel.Checklist) @@ -119,9 +119,9 @@ private static string ActionHintFor(SpellFamily family) { SpellFamily.Fire => "삼각형 꼭짓점 3개를 크게 잡고 마지막 점을 시작점 근처로 닫아 보세요.", SpellFamily.Water => "한 획으로 둥글게 돌린 뒤 끝점을 시작점 가까이에 놓아 보세요.", - SpellFamily.Wind => "짧은 평행선 3개를 서로 비슷한 간격으로 따로 그려 보세요.", + SpellFamily.Wind => "위, 가운데, 아래에 짧은 평행선 3개를 같은 간격으로 따로 그려 보세요.", SpellFamily.Earth => "윗변이 좁고 아랫변이 넓은 사다리꼴을 닫힌 모양으로 그려 보세요.", - SpellFamily.Life => "아래 줄기에서 올라와 좌우 가지로 갈라지는 열린 Y 형태를 만들어 보세요.", + SpellFamily.Life => "가운데 줄기에서 좌우 가지가 갈라지는 열린 Y 형태를 만들어 보세요.", _ => "큰 실루엣을 먼저 맞추고 세부 속도는 나중에 조정하세요." }; } @@ -132,9 +132,9 @@ private static string StrongHintFor(SpellFamily family) { SpellFamily.Fire => "불꽃은 닫힌 삼각형입니다. 아래 꼭짓점에서 시작해 위 양쪽 꼭짓점을 찍고 처음으로 돌아오세요.", SpellFamily.Water => "물은 닫힌 원입니다. 한 번에 둥글게 돌리고 끝점을 시작점 바로 옆에 놓으세요.", - SpellFamily.Wind => "바람은 도형이 아니라 세 줄입니다. 같은 방향의 짧은 선 3개만 남기세요.", + SpellFamily.Wind => "바람은 도형이 아니라 3획입니다. 위, 가운데, 아래에 같은 기울기의 짧은 선 3개만 남기세요.", SpellFamily.Earth => "땅은 닫힌 사다리꼴입니다. 네 모서리를 만들고 마지막 선으로 틈을 막으세요.", - SpellFamily.Life => "생명은 열린 가지입니다. 줄기 하나와 좌우 가지를 만들고 원처럼 닫지 마세요.", + SpellFamily.Life => "생명은 열린 Y입니다. 줄기 하나를 세운 뒤 같은 분기점에서 왼쪽 가지와 오른쪽 가지를 뻗고 끝을 닫지 마세요.", _ => "문양을 더 크게, 더 단순하게 그린 뒤 다시 시전하세요." }; } diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs index 9555ce0..c7ba111 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs @@ -94,8 +94,8 @@ public static SpellResult Recognize(IReadOnlyList> r targetFamily = targetFamily, confidence = 0f, quality = new QualityVector(), - feedbackReason = "No stroke was captured.", - nextHint = "Hold right mouse on the map floor and draw the spell." + feedbackReason = "아직 바닥에 그려진 선이 없습니다.", + nextHint = "오른쪽 마우스를 누른 채 바닥에 목표 문양을 그려 보세요." }; } @@ -109,8 +109,14 @@ public static SpellResult Recognize(IReadOnlyList> r var top = scored[0]; var second = scored.Count > 1 ? scored[1] : top; var margin = top.score - second.score; + var parallelism = EstimateParallelism(drawable); + var windSpacing = EstimateWindSpacingBalance(drawable); var status = ResolveStatus(top, margin, quality, drawable.Count); - if (targetFamily == SpellFamily.Wind && drawable.Count < 3) + if (targetFamily == SpellFamily.Wind && drawable.Count != 3) + { + status = RecognitionStatus.Incomplete; + } + else if (targetFamily == SpellFamily.Wind && (parallelism < 0.62f || windSpacing < 0.45f)) { status = RecognitionStatus.Incomplete; } @@ -129,8 +135,8 @@ public static SpellResult Recognize(IReadOnlyList> r confidence = Mathf.Clamp01(top.score), quality = quality, success = success, - feedbackReason = BuildReason(targetFamily, top, second, status, quality), - nextHint = BuildHint(targetFamily, top, status, quality, drawable.Count) + feedbackReason = BuildReason(targetFamily, top, second, status, quality, drawable.Count, parallelism, windSpacing), + nextHint = BuildHint(targetFamily, top, status, quality, drawable.Count, parallelism, windSpacing) }; } @@ -232,16 +238,24 @@ private static string BuildReason( (SpellTemplate template, float score, float distance) top, (SpellTemplate template, float score, float distance) second, RecognitionStatus status, - QualityVector quality) + QualityVector quality, + int strokeCount, + float parallelism, + float windSpacing) { if (RequiresClosure(target) && quality.closure < 0.62f) { - return "닫힌 문양의 끝점이 충분히 맞닿지 않아 미완성으로 남았습니다."; + return ClosureReasonFor(target); } - if (target == SpellFamily.Wind && status == RecognitionStatus.Incomplete) + if (target == SpellFamily.Wind && status != RecognitionStatus.Recognized) { - return "바람 문양은 평행한 선 3개가 필요합니다."; + return WindReasonFor(strokeCount, parallelism, windSpacing); + } + + if (target == SpellFamily.Life && status != RecognitionStatus.Recognized) + { + return LifeReasonFor(strokeCount, quality); } if (status == RecognitionStatus.Recognized && top.template.family == target) @@ -256,12 +270,12 @@ private static string BuildReason( if (status == RecognitionStatus.Incomplete && RequiresClosure(top.template.family) && quality.closure < 0.62f) { - return "닫힌 문양의 끝점이 충분히 맞닿지 않아 미완성으로 남았습니다."; + return ClosureReasonFor(top.template.family); } if (status == RecognitionStatus.Incomplete && top.template.family == SpellFamily.Wind) { - return "바람 문양은 평행한 선 3개가 필요합니다."; + return WindReasonFor(strokeCount, parallelism, windSpacing); } if (status == RecognitionStatus.Ambiguous) @@ -277,21 +291,28 @@ private static string BuildHint( (SpellTemplate template, float score, float distance) top, RecognitionStatus status, QualityVector quality, - int strokeCount) + int strokeCount, + float parallelism, + float windSpacing) { if (target == SpellFamily.Wind && strokeCount < 3) { - return "짧은 평행선을 3획으로 나누어 그려 보세요."; + return "마우스를 세 번 끊어 위, 가운데, 아래에 짧은 평행선을 하나씩 그려 보세요."; } - if (RequiresClosure(target) && quality.closure < 0.72f) + if (target == SpellFamily.Wind && strokeCount > 3) { - return "마지막 점을 시작점 근처로 가져와 닫힌 모양을 만들어 보세요."; + return "추가 선을 줄이고 위, 가운데, 아래 3획만 남겨 보세요."; } - if (quality.rotationBias > 0.55f) + if (target == SpellFamily.Wind && parallelism < 0.62f) { - return "문양을 조금 더 정면 방향으로 세워 그리면 안정도가 올라갑니다."; + return "세 줄을 모두 왼쪽에서 오른쪽으로 같은 기울기로 맞춰 그려 보세요."; + } + + if (target == SpellFamily.Wind && windSpacing < 0.45f) + { + return "윗줄과 가운데, 가운데와 아랫줄 사이 간격을 비슷하게 벌려 보세요."; } if (status == RecognitionStatus.Recognized && top.template.family == target) @@ -299,9 +320,96 @@ private static string BuildHint( return "좋습니다. 같은 문양을 유지하면 다음 시험으로 넘어갈 수 있습니다."; } + if (RequiresClosure(target) && quality.closure < 0.72f) + { + return ClosureHintFor(target); + } + + if (target == SpellFamily.Life && quality.closure > 0.62f) + { + return "끝점을 서로 붙이지 말고 줄기 아래와 가지 끝을 열어 둔 Y 형태로 그려 보세요."; + } + + if (target == SpellFamily.Life) + { + return "가운데 줄기를 먼저 세우고 한 지점에서 왼쪽 가지와 오른쪽 가지가 갈라지게 그려 보세요."; + } + + if (quality.rotationBias > 0.55f) + { + return "문양을 조금 더 정면 방향으로 세워 그리면 안정도가 올라갑니다."; + } + return $"{SpellLabels.Korean(target)}의 큰 실루엣을 먼저 맞추고 세부 속도는 나중에 조정하세요."; } + private static string ClosureReasonFor(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => "불꽃 삼각형의 마지막 점이 시작점으로 돌아오지 않아 틈이 남았습니다.", + SpellFamily.Water => "물 원의 끝점이 시작점 옆에 닿지 않아 열린 곡선으로 읽혔습니다.", + SpellFamily.Earth => "땅 사다리꼴의 마지막 변이 닫히지 않아 바닥이 고정되지 않았습니다.", + _ => "끝점이 시작점 가까이 닿지 않아 닫힌 문양으로 읽히지 않았습니다." + }; + } + + private static string ClosureHintFor(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => "세 꼭짓점을 찍은 뒤 마지막 선을 처음 아래 꼭짓점으로 되돌려 삼각형을 닫으세요.", + SpellFamily.Water => "한 획으로 원을 돌리고 끝점을 시작점 바로 옆에 놓아 고리를 닫으세요.", + SpellFamily.Earth => "윗변은 좁게, 아랫변은 넓게 만든 뒤 마지막 선으로 네 모서리를 닫으세요.", + _ => "마지막 점을 시작점 근처로 가져와 닫힌 모양을 만들어 보세요." + }; + } + + private static string WindReasonFor(int strokeCount, float parallelism, float spacingBalance) + { + if (strokeCount < 3) + { + return $"바람은 짧은 선 3획입니다. 지금은 {strokeCount}획만 보여 위, 가운데, 아래 흐름을 읽지 못했습니다."; + } + + if (strokeCount > 3) + { + return "바람은 세 줄만 남겨야 합니다. 획이 많아 평행한 흐름이 흐려졌습니다."; + } + + if (parallelism < 0.62f) + { + return "바람 세 줄의 기울기가 서로 달라 같은 방향의 흐름으로 읽히지 않았습니다."; + } + + if (spacingBalance < 0.45f) + { + return "바람 세 줄 사이 간격이 고르지 않아 한 묶음의 평행선으로 읽히지 않았습니다."; + } + + return "바람 세 줄의 방향과 간격이 아직 충분히 맞지 않았습니다."; + } + + private static string LifeReasonFor(int strokeCount, QualityVector quality) + { + if (quality.closure > 0.62f) + { + return "생명은 닫힌 도형이 아니라 줄기에서 가지가 갈라지는 열린 Y 형태입니다."; + } + + if (strokeCount < 2) + { + return "생명 문양에서 줄기는 보였지만 좌우로 갈라지는 가지가 부족합니다."; + } + + if (strokeCount > 3) + { + return "획이 많아 생명 문양의 한 줄기와 두 갈래 가지 구조가 흐려졌습니다."; + } + + return "생명 문양의 줄기와 좌우 가지가 만나는 분기점이 아직 분명하지 않습니다."; + } + private static IReadOnlyList BuildTemplates() { var templates = new List @@ -489,6 +597,30 @@ private static float EstimateParallelism(List> strokes) return Mathf.Clamp01(1f - deviation / (Mathf.PI / 6f)); } + private static float EstimateWindSpacingBalance(List> strokes) + { + var centers = strokes + .Where(stroke => stroke.Count >= 2) + .Select(stroke => new Vector2(stroke.Average(sample => sample.position.x), stroke.Average(sample => sample.position.y))) + .OrderBy(center => center.y) + .ToList(); + + if (centers.Count < 3) + { + return 0f; + } + + var lowerGap = Mathf.Abs(centers[1].y - centers[0].y); + var upperGap = Mathf.Abs(centers[2].y - centers[1].y); + var totalSpan = Mathf.Abs(centers[2].y - centers[0].y); + if (totalSpan < 0.001f) + { + return 0f; + } + + return Mathf.Clamp01(1f - Mathf.Abs(lowerGap - upperGap) / totalSpan); + } + private static int CountDominantCorners(List> strokes) { var dominant = strokes.OrderByDescending(stroke => StrokePathLength(stroke)).FirstOrDefault(); diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs index a16cda9..a8ede99 100644 --- a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs +++ b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs @@ -72,7 +72,8 @@ public void OpenTriangleIsIncompleteInsteadOfFalsePositive() Assert.That(result.status, Is.Not.EqualTo(RecognitionStatus.Recognized)); Assert.That(result.success, Is.False); - Assert.That(result.feedbackReason, Does.Contain("미완성").Or.Contain("닫힌")); + Assert.That(result.feedbackReason, Does.Contain("불꽃").And.Contain("틈")); + Assert.That(result.nextHint, Does.Contain("마지막 선").And.Contain("삼각형")); } [Test] @@ -88,6 +89,81 @@ public void TwoLineWindIsIncomplete() Assert.That(result.status, Is.EqualTo(RecognitionStatus.Incomplete).Or.EqualTo(RecognitionStatus.Ambiguous)); Assert.That(result.success, Is.False); + Assert.That(result.feedbackReason, Does.Contain("3획").And.Contain("위, 가운데, 아래")); + Assert.That(result.nextHint, Does.Contain("세 번").And.Contain("평행선")); + } + + [Test] + public void UnevenWindLinesExplainSpacing() + { + var strokes = new List> + { + MakeLine(70, 120, 390, 118, 0f), + MakeLine(70, 150, 390, 148, 0.2f), + MakeLine(70, 330, 390, 328, 0.4f) + }; + + var result = GestureRecognizer.Recognize(strokes, SpellFamily.Wind); + + Assert.That(result.success, Is.False); + Assert.That(result.feedbackReason, Does.Contain("간격")); + Assert.That(result.nextHint, Does.Contain("간격").And.Contain("비슷")); + } + + [Test] + public void ExtraWindStrokeRemainsIncompleteWithActionHint() + { + var strokes = new List> + { + MakeLine(70, 120, 390, 118, 0f), + MakeLine(70, 190, 390, 188, 0.2f), + MakeLine(70, 260, 390, 258, 0.4f), + MakeLine(70, 330, 390, 328, 0.6f) + }; + + var result = GestureRecognizer.Recognize(strokes, SpellFamily.Wind); + + Assert.That(result.status, Is.EqualTo(RecognitionStatus.Incomplete)); + Assert.That(result.success, Is.False); + Assert.That(result.feedbackReason, Does.Contain("세 줄").And.Contain("획이 많")); + Assert.That(result.nextHint, Does.Contain("추가 선").And.Contain("3획")); + } + + [Test] + public void LifeFailureDistinguishesStemAndBranches() + { + var strokes = new List> + { + MakeLine(220, 80, 220, 360, 0f) + }; + + var result = GestureRecognizer.Recognize(strokes, SpellFamily.Life); + + Assert.That(result.success, Is.False); + Assert.That(result.feedbackReason, Does.Contain("줄기").And.Contain("가지")); + Assert.That(result.nextHint, Does.Contain("가운데 줄기").And.Contain("왼쪽 가지").And.Contain("오른쪽 가지")); + } + + [Test] + public void SuccessfulLifeKeepsPositiveNextHint() + { + var result = GestureRecognizer.Recognize(GestureRecognizer.CreateCanonicalSamples(SpellFamily.Life), SpellFamily.Life); + + Assert.That(result.status, Is.EqualTo(RecognitionStatus.Recognized)); + Assert.That(result.success, Is.True); + Assert.That(result.nextHint, Does.Contain("좋습니다")); + Assert.That(result.nextHint, Does.Not.Contain("가지가 갈라지게")); + } + + [Test] + public void EmptyBaseFailureUsesPlayerFacingCopy() + { + var result = GestureRecognizer.Recognize(new List>(), SpellFamily.Water); + + Assert.That(result.success, Is.False); + Assert.That(result.feedbackReason, Does.Contain("바닥").And.Contain("선")); + Assert.That(result.nextHint, Does.Contain("오른쪽 마우스")); + Assert.That(result.feedbackReason, Does.Not.Contain("No stroke")); } [Test] @@ -167,6 +243,29 @@ public void RepeatedFailuresEscalateAssistLevel() Assert.That(laterFailure.currentLevel, Is.EqualTo(AssistLevel.GhostTrace)); } + [TestCase(SpellFamily.Fire, "삼각형")] + [TestCase(SpellFamily.Water, "원")] + [TestCase(SpellFamily.Wind, "3획")] + [TestCase(SpellFamily.Earth, "사다리꼴")] + [TestCase(SpellFamily.Life, "가지")] + public void RepeatedFailureCopyEscalatesWithFamilySpecificActions(SpellFamily family, string expectedWord) + { + var failedResult = GestureRecognizer.Recognize(new List>(), family); + + var checklist = HintAssistance.ForAttempt(family, 1, false, failedResult); + var strong = HintAssistance.ForAttempt(family, 2, false, failedResult); + + Assert.That(checklist.body, Does.Contain(expectedWord)); + Assert.That(strong.body, Does.Contain(expectedWord)); + Assert.That(checklist.body, Is.Not.EqualTo(strong.body)); + Assert.That(checklist.body, Does.Not.Contain("closure")); + Assert.That(checklist.body, Does.Not.Contain("Incomplete")); + Assert.That(checklist.body, Does.Not.Contain("Invalid")); + Assert.That(strong.body, Does.Not.Contain("closure")); + Assert.That(strong.body, Does.Not.Contain("Incomplete")); + Assert.That(strong.body, Does.Not.Contain("Invalid")); + } + [Test] public void SuccessAfterAssistIsLoggedAsAssisted() {