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 @@ -58,11 +58,11 @@ public static IReadOnlyList<string> 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<string>()
};
}
Expand All @@ -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)
Expand Down Expand Up @@ -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 형태를 만들어 보세요.",
_ => "큰 실루엣을 먼저 맞추고 세부 속도는 나중에 조정하세요."
};
}
Expand All @@ -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입니다. 줄기 하나를 세운 뒤 같은 분기점에서 왼쪽 가지와 오른쪽 가지를 뻗고 끝을 닫지 마세요.",
_ => "문양을 더 크게, 더 단순하게 그린 뒤 다시 시전하세요."
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ public static SpellResult Recognize(IReadOnlyList<IReadOnlyList<StrokeSample>> 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 = "오른쪽 마우스를 누른 채 바닥에 목표 문양을 그려 보세요."
};
}

Expand All @@ -109,8 +109,14 @@ public static SpellResult Recognize(IReadOnlyList<IReadOnlyList<StrokeSample>> 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;
}
Expand All @@ -129,8 +135,8 @@ public static SpellResult Recognize(IReadOnlyList<IReadOnlyList<StrokeSample>> 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)
};
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -277,31 +291,125 @@ 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)
{
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<SpellTemplate> BuildTemplates()
{
var templates = new List<SpellTemplate>
Expand Down Expand Up @@ -489,6 +597,30 @@ private static float EstimateParallelism(List<List<StrokeSample>> strokes)
return Mathf.Clamp01(1f - deviation / (Mathf.PI / 6f));
}

private static float EstimateWindSpacingBalance(List<List<StrokeSample>> 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<List<StrokeSample>> strokes)
{
var dominant = strokes.OrderByDescending(stroke => StrokePathLength(stroke)).FirstOrDefault();
Expand Down
Loading
Loading