Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ public enum OverlayOperator
MartialAxis
}

public enum OverlayScaleHint
{
None,
TooSmall,
TooLarge
}

[Serializable]
public sealed class OverlayRecognitionResult
{
Expand All @@ -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;
Expand Down Expand Up @@ -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))
{
Expand All @@ -162,8 +171,24 @@ public static OverlayRecognitionResult Recognize(
score = top.score,
shapeConfidence = top.shapeConfidence,
scaleRatio = features.scaleRatio,
scaleHint = scaleHint,
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,
scaleHint = scaleHint,
anchorZone = top.anchorZone,
feedbackReason = BuildOverlayFailureReason(top, scaleHint, ambiguous: false)
};
}

Expand All @@ -177,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에 붙었습니다."
};
Expand All @@ -190,8 +216,9 @@ public static OverlayRecognitionResult Recognize(
score = top.score,
shapeConfidence = top.shapeConfidence,
scaleRatio = features.scaleRatio,
scaleHint = scaleHint,
anchorZone = top.anchorZone,
feedbackReason = "장식 후보가 겹쳐 아직 seal에 붙이지 않았습니다. 모양을 더 단순하게 다시 그려 보세요."
feedbackReason = BuildOverlayFailureReason(top, scaleHint, ambiguous: true)
};
}

Expand All @@ -201,8 +228,9 @@ public static OverlayRecognitionResult Recognize(
score = top.score,
shapeConfidence = top.shapeConfidence,
scaleRatio = features.scaleRatio,
scaleHint = scaleHint,
anchorZone = top.anchorZone,
feedbackReason = "장식의 모양, 위치, 크기가 seal과 충분히 맞지 않았습니다."
feedbackReason = BuildOverlayFailureReason(top, scaleHint, ambiguous: false)
};
}

Expand Down Expand Up @@ -297,7 +325,64 @@ 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, OverlayScaleHint scaleHint, bool ambiguous)
{
if (scaleHint == OverlayScaleHint.TooSmall)
{
return $"{SpellLabels.Korean(top.op)} 장식처럼 보였지만 너무 작아 seal에 고정되지 않았습니다.";
}

if (scaleHint == OverlayScaleHint.TooLarge)
{
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 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
{
"upper_right" => "오른쪽 위 가장자리",
"right" => "오른쪽 가장자리",
"lower_right" => "오른쪽 아래 가장자리",
"upper" => "위쪽 가장자리",
"left" => "왼쪽 가장자리",
_ => "중심"
};
}

Expand Down Expand Up @@ -530,6 +615,8 @@ private sealed class OverlayScore
public float score;
public float shapeConfidence;
public string anchorZone = "";
public float minScale;
public float maxScale;
}

private readonly struct NormalizedGesture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ public BaseRecognitionResult CastRawBaseForTests(List<List<StrokeSample>> 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;
}
Expand Down Expand Up @@ -264,6 +264,12 @@ private ProcessedSpell ProcessSpellGroup(List<List<StrokeSample>> 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);
}

Expand Down Expand Up @@ -346,6 +352,22 @@ private ProcessedSpell ProcessOverlay(List<List<StrokeSample>> 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
Expand All @@ -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<List<StrokeSample>> 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))
Expand Down Expand Up @@ -411,29 +459,49 @@ 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)
if (result.scaleHint == OverlayScaleHint.TooSmall)
{
return "장식이 너무 작습니다. seal 중심을 기준으로 조금 더 크게 그려 보세요.";
}

if (result.scaleRatio > 0.64f)
if (result.scaleHint == OverlayScaleHint.TooLarge)
{
return "장식이 너무 큽니다. seal 안쪽에 들어오도록 작게 줄여 보세요.";
}

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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,22 @@ 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.scaleHint, Is.EqualTo(OverlayScaleHint.TooSmall));
Assert.That(result.feedbackReason, Does.Contain("너무 작"));
}

[Test]
Expand Down
Loading
Loading