From 1b3fec14e6bf7f032db63fccb6301cea62c10870 Mon Sep 17 00:00:00 2001 From: SilverSupplier <192233040+SilverSupplier@users.noreply.github.com> Date: Sun, 17 May 2026 23:41:57 +0900 Subject: [PATCH 1/2] fix: improve overlay failure hints --- .../Scripts/Core/SpellRuntime.cs | 66 ++++++++++++++- .../Scripts/Runtime/ExamGameController.cs | 84 ++++++++++++++++++- .../Tests/EditMode/GestureRecognizerTests.cs | 16 +++- .../PlayMode/MagicExamHallSceneSmokeTests.cs | 43 ++++++++++ 4 files changed, 202 insertions(+), 7 deletions(-) diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs index 6e45f65..b3f6774 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs @@ -163,7 +163,21 @@ public static OverlayRecognitionResult Recognize( shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, anchorZone = top.anchorZone, - feedbackReason = "축 장식은 먼저 절단(void_cut)이 붙은 seal에서만 고정됩니다. 먼저 대각선 절단을 붙인 뒤 축을 그리세요." + feedbackReason = "축 장식은 먼저 절단 장식이 붙은 seal에서만 섭니다. 대각선 절단을 붙인 뒤 중심을 가르는 축을 그리세요." + }; + } + + if (top.shapeConfidence >= 0.74f && ScaleIsFarOutside(features.scaleRatio, top)) + { + return new OverlayRecognitionResult + { + status = RecognitionStatus.Incomplete, + recognizedOperator = top.op, + score = top.score, + shapeConfidence = top.shapeConfidence, + scaleRatio = features.scaleRatio, + anchorZone = top.anchorZone, + feedbackReason = BuildOverlayFailureReason(top, features, ambiguous: false) }; } @@ -191,7 +205,7 @@ public static OverlayRecognitionResult Recognize( shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, anchorZone = top.anchorZone, - feedbackReason = "장식 후보가 겹쳐 아직 seal에 붙이지 않았습니다. 모양을 더 단순하게 다시 그려 보세요." + feedbackReason = BuildOverlayFailureReason(top, features, ambiguous: true) }; } @@ -202,7 +216,7 @@ public static OverlayRecognitionResult Recognize( shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, anchorZone = top.anchorZone, - feedbackReason = "장식의 모양, 위치, 크기가 seal과 충분히 맞지 않았습니다." + feedbackReason = BuildOverlayFailureReason(top, features, ambiguous: false) }; } @@ -297,7 +311,49 @@ private static OverlayScore ScoreTemplate( op = template.op, score = Mathf.Clamp01(score), shapeConfidence = Mathf.Clamp01(shape), - anchorZone = template.preferredAnchor + anchorZone = template.preferredAnchor, + minScale = template.minScale, + maxScale = template.maxScale + }; + } + + private static string BuildOverlayFailureReason(OverlayScore top, OverlayFeatures features, bool ambiguous) + { + if (features.scaleRatio > 0f && features.scaleRatio < top.minScale * 0.75f) + { + return $"{SpellLabels.Korean(top.op)} 장식처럼 보였지만 너무 작아 seal에 고정되지 않았습니다."; + } + + if (features.scaleRatio > top.maxScale * 1.15f) + { + return $"{SpellLabels.Korean(top.op)} 장식처럼 보였지만 너무 커서 seal 안쪽 기준을 벗어났습니다."; + } + + if (top.shapeConfidence >= 0.55f) + { + return $"{SpellLabels.Korean(top.op)} 장식 모양은 보였지만 위치가 {AnchorLabel(top.anchorZone)} 기준과 맞지 않았습니다."; + } + + return ambiguous + ? "장식 후보가 겹쳐 아직 seal에 붙이지 않았습니다. 한 번에 한 가지 장식만 더 단순하게 그려 보세요." + : "장식의 모양과 위치가 seal 기준과 충분히 맞지 않았습니다."; + } + + private static bool ScaleIsFarOutside(float scaleRatio, OverlayScore top) + { + return scaleRatio > 0f && (scaleRatio < top.minScale * 0.55f || scaleRatio > top.maxScale * 1.35f); + } + + private static string AnchorLabel(string anchorZone) + { + return anchorZone switch + { + "upper_right" => "오른쪽 위 가장자리", + "right" => "오른쪽 가장자리", + "lower_right" => "오른쪽 아래 가장자리", + "upper" => "위쪽 가장자리", + "left" => "왼쪽 가장자리", + _ => "중심" }; } @@ -530,6 +586,8 @@ private sealed class OverlayScore public float score; public float shapeConfidence; public string anchorZone = ""; + public float minScale; + public float maxScale; } private readonly struct NormalizedGesture diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs index ec0f9c3..427f832 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs @@ -264,6 +264,12 @@ private ProcessedSpell ProcessSpellGroup(List> strokes, Vecto return ProcessOverlay(strokes, center, strokeCount, nearestSeal); } + var detachedOverlay = FindDetachedOverlayCandidate(strokes, center); + if (detachedOverlay != null) + { + return ProcessDetachedOverlay(center, strokeCount, detachedOverlay); + } + return ProcessBase(strokes, center, strokeCount); } @@ -346,6 +352,22 @@ private ProcessedSpell ProcessOverlay(List> strokes, Vector2 return new ProcessedSpell { overlayResult = result }; } + private ProcessedSpell ProcessDetachedOverlay( + Vector2 center, + int strokeCount, + DetachedOverlayCandidate candidate) + { + var result = candidate.result; + result.status = RecognitionStatus.Invalid; + result.feedbackReason = BuildDetachedOverlayReason(result, candidate.sealView.seal, center); + CurrentAssistLevel = 1; + LastHintText = DetachedOverlayActionHint(candidate.sealView.seal); + magicNote.Show(BuildDetachedOverlayFailureNote(result, candidate.sealView.seal)); + pulses.Add(new ParticlePulse(center, new Color(0.75f, 0.75f, 0.82f), weak: true)); + LogOverlayAttempt(result, candidate.sealView.seal, center, strokeCount, "detached_overlay"); + return new ProcessedSpell { overlayResult = result }; + } + private SealView FindAttachableSeal(Vector2 center) { return seals @@ -354,6 +376,32 @@ private SealView FindAttachableSeal(Vector2 center) .FirstOrDefault(seal => Vector2.Distance(center, seal.seal.worldCenter) <= Mathf.Max(1.35f, seal.seal.worldScale * 0.95f)); } + private DetachedOverlayCandidate FindDetachedOverlayCandidate(List> strokes, Vector2 center) + { + var nearestSeal = seals + .Where(seal => Time.time <= seal.seal.expiresAt) + .OrderBy(seal => Vector2.Distance(center, seal.seal.worldCenter)) + .FirstOrDefault(); + if (nearestSeal == null) + { + return null; + } + + var basePreview = SpellRuntime.RecognizeBase(strokes); + if (basePreview.spell.status == RecognitionStatus.Recognized && basePreview.spell.recognizedFamily.HasValue) + { + return null; + } + + var result = OverlayRecognizer.Recognize(strokes, nearestSeal.seal); + if (result.success || result.recognizedOperator.HasValue || result.score >= 0.48f || result.shapeConfidence >= 0.55f) + { + return new DetachedOverlayCandidate(nearestSeal, result); + } + + return null; + } + private GoalEffect ApplyBaseToGoals(SpellFamily family, Vector2 center) { foreach (var goal in activeGoals.Where(goal => !goal.completed)) @@ -411,14 +459,22 @@ private static string BuildOverlayFailureNote(OverlayRecognitionResult result, C private static string BuildOverlaySuccessNote(CompiledSeal seal, OverlayOperator op, GoalEffect effect) { - return $"{SpellLabels.Korean(op)} 장식 성공. {seal.Label}\n{effect.note}"; + return $"{SpellLabels.Korean(op)} 장식이 seal 가장자리에 붙었습니다.\n현재 seal: {seal.Label}\n{effect.note}"; + } + + private static string BuildDetachedOverlayFailureNote(OverlayRecognitionResult result, CompiledSeal seal) + { + return + "노트: 장식이 seal에 안정적으로 붙지 않았습니다.\n" + + $"{result.feedbackReason}\n" + + $"다음: {DetachedOverlayActionHint(seal)}"; } private static string OverlayActionHint(OverlayRecognitionResult result, CompiledSeal seal) { if (result.recognizedOperator == OverlayOperator.MartialAxis && !seal.overlayStack.Contains(OverlayOperator.VoidCut)) { - return "먼저 같은 seal에 대각선 절단(void_cut)을 붙인 뒤, 중심을 가르는 축을 다시 그리세요."; + return "먼저 같은 seal에 대각선 절단 장식을 붙인 뒤, 중심을 가르는 축을 다시 그리세요."; } if (result.scaleRatio > 0f && result.scaleRatio < 0.10f) @@ -434,6 +490,18 @@ private static string OverlayActionHint(OverlayRecognitionResult result, Compile return AnchorHint(result.anchorZone); } + private static string BuildDetachedOverlayReason(OverlayRecognitionResult result, CompiledSeal seal, Vector2 center) + { + var operatorName = result.recognizedOperator.HasValue ? SpellLabels.Korean(result.recognizedOperator.Value) : "장식"; + var distance = Vector2.Distance(center, seal.worldCenter); + return $"{operatorName} 모양은 보였지만 seal에서 너무 멀어 붙지 않았습니다. 현재 거리 {distance:0.0}, seal 중심 가까이에서 다시 그리세요."; + } + + private static string DetachedOverlayActionHint(CompiledSeal seal) + { + return $"{SpellLabels.Korean(seal.baseFamily)} seal의 빛나는 원 안쪽이나 가장자리 바로 옆에 장식을 다시 그리세요."; + } + private static string AnchorHint(string anchorZone) { return anchorZone switch @@ -870,6 +938,18 @@ private sealed class ProcessedSpell public OverlayRecognitionResult overlayResult = null; } + private sealed class DetachedOverlayCandidate + { + public readonly SealView sealView; + public readonly OverlayRecognitionResult result; + + public DetachedOverlayCandidate(SealView sealView, OverlayRecognitionResult result) + { + this.sealView = sealView; + this.result = result; + } + } + private sealed class ParticlePulse { public readonly Vector2 position; diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs index a16cda9..e4cebcd 100644 --- a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs +++ b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs @@ -55,7 +55,21 @@ public void MartialAxisRequiresVoidCutInSealStack() Assert.That(result.status, Is.Not.EqualTo(RecognitionStatus.Recognized)); Assert.That(result.success, Is.False); Assert.That(result.recognizedOperator, Is.EqualTo(OverlayOperator.MartialAxis)); - Assert.That(result.feedbackReason, Does.Contain("절단").And.Contain("void_cut")); + Assert.That(result.feedbackReason, Does.Contain("절단 장식").And.Contain("축")); + Assert.That(result.feedbackReason, Does.Not.Contain("void_cut")); + } + + [Test] + public void TinyOverlayExplainsScaleMismatch() + { + var seal = CreateWorldSeal(); + var strokes = OverlayRecognizer.CreateCanonicalSamples(OverlayOperator.IceBar, seal.worldCenter, seal.worldScale * 0.03f); + var result = OverlayRecognizer.Recognize(strokes, seal); + + Assert.That(result.status, Is.Not.EqualTo(RecognitionStatus.Recognized)); + Assert.That(result.success, Is.False); + Assert.That(result.recognizedOperator, Is.EqualTo(OverlayOperator.IceBar)); + Assert.That(result.feedbackReason, Does.Contain("너무 작")); } [Test] diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs index ce7a9a6..68d94d7 100644 --- a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs @@ -71,6 +71,49 @@ public IEnumerator OverlayAttachesToSealStack() Assert.That(controller.LastOverlayStack.Contains(OverlayOperator.MartialAxis), Is.True); } + [UnityTest] + public IEnumerator DetachedOverlayShowsSealProximityHint() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); + var result = controller.CastSyntheticOverlayForTests(OverlayOperator.IceBar, new Vector2(4.8f, 0f)); + yield return null; + + Assert.That(result, Is.Not.Null); + Assert.That(result.success, Is.False); + Assert.That(result.recognizedOperator, Is.EqualTo(OverlayOperator.IceBar)); + Assert.That(controller.LastMagicNoteText, Does.Contain("seal에서 너무 멀")); + Assert.That(controller.LastMagicNoteText, Does.Contain("빛나는 원")); + Assert.That(controller.LastHintText, Does.Contain("빛나는 원")); + } + + [UnityTest] + public IEnumerator MartialAxisFailureUsesPlayerFacingDependencyHint() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); + var result = controller.CastSyntheticOverlayForTests(OverlayOperator.MartialAxis, Vector2.zero); + yield return null; + + Assert.That(result.success, Is.False); + Assert.That(result.recognizedOperator, Is.EqualTo(OverlayOperator.MartialAxis)); + Assert.That(controller.LastMagicNoteText, Does.Contain("절단 장식")); + Assert.That(controller.LastMagicNoteText, Does.Not.Contain("void_cut")); + Assert.That(controller.LastHintText, Does.Contain("절단 장식")); + } + [UnityTest] public IEnumerator FailedBaseCastsEscalateMagicNoteHints() { From 12bdacba6d20c888185c24af6dfe3f0724d15bcc Mon Sep 17 00:00:00 2001 From: SilverSupplier <192233040+SilverSupplier@users.noreply.github.com> Date: Sun, 17 May 2026 23:50:10 +0900 Subject: [PATCH 2/2] fix: align overlay scale action hints --- .../Scripts/Core/SpellRuntime.cs | 41 ++++++++++++++++--- .../Scripts/Runtime/ExamGameController.cs | 8 ++-- .../Tests/EditMode/GestureRecognizerTests.cs | 1 + .../PlayMode/MagicExamHallSceneSmokeTests.cs | 21 ++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs index b3f6774..383369e 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs @@ -22,6 +22,13 @@ public enum OverlayOperator MartialAxis } + public enum OverlayScaleHint + { + None, + TooSmall, + TooLarge + } + [Serializable] public sealed class OverlayRecognitionResult { @@ -30,6 +37,7 @@ public sealed class OverlayRecognitionResult public float score; public float shapeConfidence; public float scaleRatio; + public OverlayScaleHint scaleHint; public string anchorZone = ""; public string feedbackReason = ""; public bool success => status == RecognitionStatus.Recognized && recognizedOperator.HasValue; @@ -152,6 +160,7 @@ public static OverlayRecognitionResult Recognize( var top = scored[0]; var second = scored.Count > 1 ? scored[1] : top; var margin = top.score - second.score; + var scaleHint = ScaleHintFor(features.scaleRatio, top); if (top.op == OverlayOperator.MartialAxis && !seal.overlayStack.Contains(OverlayOperator.VoidCut)) { @@ -162,6 +171,7 @@ public static OverlayRecognitionResult Recognize( score = top.score, shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, + scaleHint = scaleHint, anchorZone = top.anchorZone, feedbackReason = "축 장식은 먼저 절단 장식이 붙은 seal에서만 섭니다. 대각선 절단을 붙인 뒤 중심을 가르는 축을 그리세요." }; @@ -176,8 +186,9 @@ public static OverlayRecognitionResult Recognize( score = top.score, shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, + scaleHint = scaleHint, anchorZone = top.anchorZone, - feedbackReason = BuildOverlayFailureReason(top, features, ambiguous: false) + feedbackReason = BuildOverlayFailureReason(top, scaleHint, ambiguous: false) }; } @@ -191,6 +202,7 @@ public static OverlayRecognitionResult Recognize( score = top.score, shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, + scaleHint = scaleHint, anchorZone = top.anchorZone, feedbackReason = $"{SpellLabels.Korean(top.op)} 장식이 seal에 붙었습니다." }; @@ -204,8 +216,9 @@ public static OverlayRecognitionResult Recognize( score = top.score, shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, + scaleHint = scaleHint, anchorZone = top.anchorZone, - feedbackReason = BuildOverlayFailureReason(top, features, ambiguous: true) + feedbackReason = BuildOverlayFailureReason(top, scaleHint, ambiguous: true) }; } @@ -215,8 +228,9 @@ public static OverlayRecognitionResult Recognize( score = top.score, shapeConfidence = top.shapeConfidence, scaleRatio = features.scaleRatio, + scaleHint = scaleHint, anchorZone = top.anchorZone, - feedbackReason = BuildOverlayFailureReason(top, features, ambiguous: false) + feedbackReason = BuildOverlayFailureReason(top, scaleHint, ambiguous: false) }; } @@ -317,14 +331,14 @@ private static OverlayScore ScoreTemplate( }; } - private static string BuildOverlayFailureReason(OverlayScore top, OverlayFeatures features, bool ambiguous) + private static string BuildOverlayFailureReason(OverlayScore top, OverlayScaleHint scaleHint, bool ambiguous) { - if (features.scaleRatio > 0f && features.scaleRatio < top.minScale * 0.75f) + if (scaleHint == OverlayScaleHint.TooSmall) { return $"{SpellLabels.Korean(top.op)} 장식처럼 보였지만 너무 작아 seal에 고정되지 않았습니다."; } - if (features.scaleRatio > top.maxScale * 1.15f) + if (scaleHint == OverlayScaleHint.TooLarge) { return $"{SpellLabels.Korean(top.op)} 장식처럼 보였지만 너무 커서 seal 안쪽 기준을 벗어났습니다."; } @@ -344,6 +358,21 @@ private static bool ScaleIsFarOutside(float scaleRatio, OverlayScore top) return scaleRatio > 0f && (scaleRatio < top.minScale * 0.55f || scaleRatio > top.maxScale * 1.35f); } + private static OverlayScaleHint ScaleHintFor(float scaleRatio, OverlayScore top) + { + if (scaleRatio > 0f && scaleRatio < top.minScale * 0.75f) + { + return OverlayScaleHint.TooSmall; + } + + if (scaleRatio > top.maxScale * 1.15f) + { + return OverlayScaleHint.TooLarge; + } + + return OverlayScaleHint.None; + } + private static string AnchorLabel(string anchorZone) { return anchorZone switch diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs index 427f832..ed0198e 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs @@ -93,10 +93,10 @@ public BaseRecognitionResult CastRawBaseForTests(List> stroke return ProcessBase(strokes, worldCenter, strokes.Count).baseResult; } - public OverlayRecognitionResult CastSyntheticOverlayForTests(OverlayOperator op, Vector2 worldCenter) + public OverlayRecognitionResult CastSyntheticOverlayForTests(OverlayOperator op, Vector2 worldCenter, float sealScaleRatio = 0.24f) { var nearestSeal = FindAttachableSeal(worldCenter); - var scale = nearestSeal == null ? 0.48f : nearestSeal.seal.worldScale * 0.24f; + var scale = nearestSeal == null ? 0.48f : nearestSeal.seal.worldScale * sealScaleRatio; var strokes = OverlayRecognizer.CreateCanonicalSamples(op, worldCenter, scale, 0.03f); return ProcessSpellGroup(strokes, worldCenter, strokes.Count).overlayResult; } @@ -477,12 +477,12 @@ private static string OverlayActionHint(OverlayRecognitionResult result, Compile return "먼저 같은 seal에 대각선 절단 장식을 붙인 뒤, 중심을 가르는 축을 다시 그리세요."; } - if (result.scaleRatio > 0f && result.scaleRatio < 0.10f) + if (result.scaleHint == OverlayScaleHint.TooSmall) { return "장식이 너무 작습니다. seal 중심을 기준으로 조금 더 크게 그려 보세요."; } - if (result.scaleRatio > 0.64f) + if (result.scaleHint == OverlayScaleHint.TooLarge) { return "장식이 너무 큽니다. seal 안쪽에 들어오도록 작게 줄여 보세요."; } diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs index e4cebcd..63223ec 100644 --- a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs +++ b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs @@ -69,6 +69,7 @@ public void TinyOverlayExplainsScaleMismatch() Assert.That(result.status, Is.Not.EqualTo(RecognitionStatus.Recognized)); Assert.That(result.success, Is.False); Assert.That(result.recognizedOperator, Is.EqualTo(OverlayOperator.IceBar)); + Assert.That(result.scaleHint, Is.EqualTo(OverlayScaleHint.TooSmall)); Assert.That(result.feedbackReason, Does.Contain("너무 작")); } diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs index 68d94d7..fb83dda 100644 --- a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs @@ -114,6 +114,27 @@ public IEnumerator MartialAxisFailureUsesPlayerFacingDependencyHint() Assert.That(controller.LastHintText, Does.Contain("절단 장식")); } + [UnityTest] + public IEnumerator OversizedOverlayShowsScaleActionHint() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); + var result = controller.CastSyntheticOverlayForTests(OverlayOperator.SoulDot, Vector2.zero, 1f); + yield return null; + + Assert.That(result.success, Is.False); + Assert.That(result.recognizedOperator, Is.EqualTo(OverlayOperator.SoulDot)); + Assert.That(result.scaleHint, Is.EqualTo(OverlayScaleHint.TooLarge)); + Assert.That(controller.LastMagicNoteText, Does.Contain("너무 커")); + Assert.That(controller.LastHintText, Does.Contain("너무 큽니다")); + } + [UnityTest] public IEnumerator FailedBaseCastsEscalateMagicNoteHints() {