diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs index e9ce5b4..1d1733c 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs @@ -197,6 +197,7 @@ private static void CreateController(Camera camera, Transform player, Canvas can controller.canvas = canvas; var drawing = controllerObject.AddComponent(); drawing.mainCamera = camera; + drawing.ApplyPlayableDefaults(); } private static void SavePrefabs(GameObject player) diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs index 6e45f65..6c095d6 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs @@ -73,6 +73,8 @@ public sealed class BaseRecognitionResult public static class SpellRuntime { + public const float DefaultSealDurationSeconds = 11f; + public static BaseRecognitionResult RecognizeBase(IReadOnlyList> strokes) { var candidates = Enum.GetValues(typeof(SpellFamily)) @@ -93,7 +95,7 @@ public static BaseRecognitionResult RecognizeBase(IReadOnlyList floorController?.FloorCount ?? 5; public int ActiveSealCount => seals.Count; public int ActiveGoalCount => activeGoals.Count; + public int CompletedGoalCountForTests => activeGoals.Count(goal => goal.completed); public Vector2 PlayerPosition => player == null ? Vector2.zero : player.position; public bool HasEndingReport => reportPanel != null && reportPanel.gameObject.activeSelf; public bool IsDrawingPanelVisible => false; @@ -55,6 +56,7 @@ public sealed class ExamGameController : MonoBehaviour public string LastHintText { get; private set; } = ""; public string LastMagicNoteText => magicNote?.Text ?? ""; public string OutputDirectory => logger?.OutputDirectory ?? ""; + public float LastSealLifetimeSecondsForTests => seals.Count == 0 ? 0f : seals[^1].seal.expiresAt - seals[^1].seal.createdAt; public IReadOnlyList LastOverlayStack => seals.Count == 0 ? Array.Empty() : seals[^1].seal.overlayStack; private void Awake() @@ -184,6 +186,7 @@ private void ConfigureWorldDrawing() { worldDrawing = gameObject.GetComponent() ?? gameObject.AddComponent(); worldDrawing.mainCamera = mainCamera; + worldDrawing.ApplyPlayableDefaults(); worldDrawing.SpellBuffered += OnSpellBuffered; } @@ -1138,6 +1141,9 @@ public static List BuildAll() public sealed class WorldStateGoal { + private const float OverlayGoalRadius = 1.45f; + private const float ComboGoalRadius = 2.05f; + public string id; public string title; public Vector2 position; @@ -1178,7 +1184,7 @@ public static WorldStateGoal Overlay(string id, string title, OverlayOperator op return new WorldStateGoal(id, title, position, color, PixelSpriteKind.RuneCircle, note) { requiredOverlay = op, - radius = 99f, + radius = OverlayGoalRadius, visualScale = 0.75f }; } @@ -1189,7 +1195,7 @@ public static WorldStateGoal Combo(string id, string title, SpellFamily family, { comboBase = family, comboOverlay = op, - radius = 99f, + radius = ComboGoalRadius, visualScale = 0.85f }; } @@ -1201,6 +1207,11 @@ public bool MatchesBase(SpellFamily family, Vector2 center) public bool MatchesOverlay(CompiledSeal seal, OverlayOperator op, Vector2 center) { + if (!CastTouchedGoalArea(seal, center)) + { + return false; + } + if (requiredOverlay == op) { return true; @@ -1209,6 +1220,12 @@ public bool MatchesOverlay(CompiledSeal seal, OverlayOperator op, Vector2 center return comboBase == seal.baseFamily && comboOverlay == op; } + private bool CastTouchedGoalArea(CompiledSeal seal, Vector2 center) + { + return Vector2.Distance(center, position) <= radius || + Vector2.Distance(seal.worldCenter, position) <= radius; + } + public WorldStateGoal Clone() { return new WorldStateGoal(id, title, position, color, kind, discoveryNote) diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs index 4cb57d2..5cca45d 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs @@ -8,9 +8,13 @@ namespace MagicExamHall { public sealed class WorldDrawingController : MonoBehaviour { + public const float DefaultBufferSeconds = 1.05f; + public const float DefaultMinPointDistance = 0.05f; + public const float StrokeVisualLifetimeSeconds = 2.3f; + public Camera mainCamera = null!; - public float bufferSeconds = 0.8f; - public float minPointDistance = 0.06f; + public float bufferSeconds = DefaultBufferSeconds; + public float minPointDistance = DefaultMinPointDistance; public Color strokeColor = new(0.22f, 0.95f, 1f, 0.92f); private readonly List> bufferedStrokes = new(); @@ -24,6 +28,12 @@ public sealed class WorldDrawingController : MonoBehaviour public bool HasBufferedInput => bufferedStrokes.Count > 0 || activeStroke.Count > 0; + public void ApplyPlayableDefaults() + { + bufferSeconds = DefaultBufferSeconds; + minPointDistance = DefaultMinPointDistance; + } + private void Awake() { mainCamera ??= Camera.main; @@ -161,7 +171,7 @@ private void TickVisuals() { var visual = visuals[index]; visual.age += Time.deltaTime; - var alpha = Mathf.Lerp(0.92f, 0f, visual.age / 1.8f); + var alpha = Mathf.Lerp(0.92f, 0f, visual.age / StrokeVisualLifetimeSeconds); var color = new Color(strokeColor.r, strokeColor.g, strokeColor.b, alpha); if (visual.line != null) { @@ -169,7 +179,7 @@ private void TickVisuals() visual.line.endColor = color; } - if (visual.age >= 1.8f) + if (visual.age >= StrokeVisualLifetimeSeconds) { if (visual.body != null) { diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs index a16cda9..0e1e08d 100644 --- a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs +++ b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs @@ -58,6 +58,15 @@ public void MartialAxisRequiresVoidCutInSealStack() Assert.That(result.feedbackReason, Does.Contain("절단").And.Contain("void_cut")); } + [Test] + public void DefaultSealLifetimeLeavesOverlaySetupTime() + { + var seal = CreateWorldSeal(); + + Assert.That(seal.expiresAt - seal.createdAt, Is.EqualTo(SpellRuntime.DefaultSealDurationSeconds).Within(0.001f)); + Assert.That(SpellRuntime.DefaultSealDurationSeconds, Is.GreaterThanOrEqualTo(10f)); + } + [Test] public void OpenTriangleIsIncompleteInsteadOfFalsePositive() { diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs index ce7a9a6..c005ed1 100644 --- a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs @@ -28,7 +28,10 @@ public IEnumerator SceneLoadsWithWorldCastingGameObjects() Assert.That(controller.ActiveGoalCount, Is.EqualTo(5)); Assert.That(Object.FindFirstObjectByType(), Is.Not.Null); Assert.That(Object.FindFirstObjectByType(), Is.Not.Null); - Assert.That(Object.FindFirstObjectByType(), Is.Not.Null); + var drawing = Object.FindFirstObjectByType(); + Assert.That(drawing, Is.Not.Null); + Assert.That(drawing.bufferSeconds, Is.EqualTo(WorldDrawingController.DefaultBufferSeconds).Within(0.001f)); + Assert.That(drawing.minPointDistance, Is.EqualTo(WorldDrawingController.DefaultMinPointDistance).Within(0.001f)); Assert.That(controller.OutputDirectory, Does.Contain("MagicExamHallLogs")); } @@ -48,6 +51,7 @@ public IEnumerator SyntheticBaseCastCreatesWorldSealWithoutPanel() Assert.That(result.spell.status, Is.EqualTo(RecognitionStatus.Recognized)); Assert.That(result.spell.recognizedFamily, Is.EqualTo(SpellFamily.Fire)); Assert.That(controller.ActiveSealCount, Is.EqualTo(1)); + Assert.That(controller.LastSealLifetimeSecondsForTests, Is.EqualTo(SpellRuntime.DefaultSealDurationSeconds).Within(0.001f)); Assert.That(controller.IsDrawingPanelVisible, Is.False); Assert.That(controller.IsResultPanelVisible, Is.False); } @@ -71,6 +75,43 @@ public IEnumerator OverlayAttachesToSealStack() Assert.That(controller.LastOverlayStack.Contains(OverlayOperator.MartialAxis), Is.True); } + [UnityTest] + public IEnumerator OverlayAndComboGoalsRequireNearbyWorldCasting() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.LoadFloorForTests(1); + controller.CastSyntheticBaseForTests(SpellFamily.Fire, Vector2.zero); + var offTargetOverlay = controller.CastSyntheticOverlayForTests(OverlayOperator.IceBar, Vector2.zero); + yield return null; + Assert.That(offTargetOverlay.success, Is.True); + Assert.That(controller.CompletedGoalCountForTests, Is.EqualTo(0)); + + controller.CastSyntheticBaseForTests(SpellFamily.Fire, new Vector2(-0.65f, 3.0f)); + var onTargetOverlay = controller.CastSyntheticOverlayForTests(OverlayOperator.IceBar, new Vector2(-0.65f, 3.0f)); + yield return null; + Assert.That(onTargetOverlay.success, Is.True); + Assert.That(controller.CompletedGoalCountForTests, Is.EqualTo(1)); + + controller.LoadFloorForTests(2); + controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); + var offTargetCombo = controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, Vector2.zero); + yield return null; + Assert.That(offTargetCombo.success, Is.True); + Assert.That(controller.CompletedGoalCountForTests, Is.EqualTo(0)); + + controller.CastSyntheticBaseForTests(SpellFamily.Earth, new Vector2(-4.6f, 1.8f)); + var onTargetCombo = controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, new Vector2(-4.6f, 1.8f)); + yield return null; + Assert.That(onTargetCombo.success, Is.True); + Assert.That(controller.CompletedGoalCountForTests, Is.EqualTo(1)); + } + [UnityTest] public IEnumerator FailedBaseCastsEscalateMagicNoteHints() { diff --git a/unity/MagicExamHall/README.md b/unity/MagicExamHall/README.md index 02265dd..fc7ab9f 100644 --- a/unity/MagicExamHall/README.md +++ b/unity/MagicExamHall/README.md @@ -19,7 +19,7 @@ Magic Exam Hall/Rebuild Demo Scene - Move: WASD or arrow keys - Draw spell: hold right mouse button on the map floor - Cast: release right mouse button -- Multi-stroke input: start the next stroke within 0.8 seconds +- Multi-stroke input: start the next stroke within about 1 second There is no default drawing panel, cast button, or station modal in the playable flow. @@ -38,6 +38,7 @@ The first implementation is thin but complete: start on floor 1, climb through a - Base families: fire, water, wind, earth, life - Overlay operators: steel_brace, electric_fork, ice_bar, soul_dot, void_cut, martial_axis - `martial_axis` requires `void_cut` to already be attached to the same seal. +- Overlay and combo goals only react when the attached seal or overlay stroke is near the target object. - Failed recognition creates a weak ripple and a short magic-note hint instead of health loss or death. ## Logs