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 @@ -197,6 +197,7 @@ private static void CreateController(Camera camera, Transform player, Canvas can
controller.canvas = canvas;
var drawing = controllerObject.AddComponent<WorldDrawingController>();
drawing.mainCamera = camera;
drawing.ApplyPlayableDefaults();
}

private static void SavePrefabs(GameObject player)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public sealed class BaseRecognitionResult

public static class SpellRuntime
{
public const float DefaultSealDurationSeconds = 11f;

public static BaseRecognitionResult RecognizeBase(IReadOnlyList<IReadOnlyList<StrokeSample>> strokes)
{
var candidates = Enum.GetValues(typeof(SpellFamily))
Expand All @@ -93,7 +95,7 @@ public static BaseRecognitionResult RecognizeBase(IReadOnlyList<IReadOnlyList<St
};
}

public static CompiledSeal CreateSeal(BaseRecognitionResult baseResult, float now, float durationSeconds = 7.5f)
public static CompiledSeal CreateSeal(BaseRecognitionResult baseResult, float now, float durationSeconds = DefaultSealDurationSeconds)
{
return new CompiledSeal
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public sealed class ExamGameController : MonoBehaviour
public int FloorCount => 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;
Expand All @@ -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<OverlayOperator> LastOverlayStack => seals.Count == 0 ? Array.Empty<OverlayOperator>() : seals[^1].seal.overlayStack;

private void Awake()
Expand Down Expand Up @@ -184,6 +186,7 @@ private void ConfigureWorldDrawing()
{
worldDrawing = gameObject.GetComponent<WorldDrawingController>() ?? gameObject.AddComponent<WorldDrawingController>();
worldDrawing.mainCamera = mainCamera;
worldDrawing.ApplyPlayableDefaults();
worldDrawing.SpellBuffered += OnSpellBuffered;
}

Expand Down Expand Up @@ -1138,6 +1141,9 @@ public static List<FloorDefinition> 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;
Expand Down Expand Up @@ -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
};
}
Expand All @@ -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
};
}
Expand All @@ -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;
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<StrokeSample>> bufferedStrokes = new();
Expand All @@ -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;
Expand Down Expand Up @@ -161,15 +171,15 @@ 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)
{
visual.line.startColor = color;
visual.line.endColor = color;
}

if (visual.age >= 1.8f)
if (visual.age >= StrokeVisualLifetimeSeconds)
{
if (visual.body != null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ public IEnumerator SceneLoadsWithWorldCastingGameObjects()
Assert.That(controller.ActiveGoalCount, Is.EqualTo(5));
Assert.That(Object.FindFirstObjectByType<Canvas>(), Is.Not.Null);
Assert.That(Object.FindFirstObjectByType<EventSystem>(), Is.Not.Null);
Assert.That(Object.FindFirstObjectByType<WorldDrawingController>(), Is.Not.Null);
var drawing = Object.FindFirstObjectByType<WorldDrawingController>();
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"));
}

Expand All @@ -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);
}
Expand All @@ -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<ExamGameController>();
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()
{
Expand Down
3 changes: 2 additions & 1 deletion unity/MagicExamHall/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
Loading