diff --git a/.gitignore b/.gitignore index da04896..7488701 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,21 @@ __pycache__/ .codex chat task-* -*.md -.gitignore -*.png \ No newline at end of file + +# Unity generated state +unity/**/Library/ +unity/**/Temp/ +unity/**/Obj/ +unity/**/Build/ +unity/**/Builds/ +unity/**/Logs/ +unity/**/UserSettings/ +unity/**/*.csproj +unity/**/*.sln +unity/**/*.user +unity/**/*.pidb +unity/**/*.booproj +unity/**/*.svd +unity/**/TestResults.xml +unity/**/PlayModeTestResults.xml +unity/**/*.log diff --git a/README.md b/README.md index a60a259..4e99958 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,12 @@ * `final seal` compile 결과 표시 * JSON 로그 export * 문서 상태 동기화 검증 +* Unity 기반 `Magic Exam Hall` 5층 월드 캐스팅 플레이 루프 ## 요구 사항 +웹 프로토타입: + * Node.js `20.x` 이상 * npm `10.x` 이상 @@ -25,6 +28,11 @@ node -v npm -v ``` +Unity 플레이어블: + +* Unity `6000.3.14f1` +* 유효한 Unity Editor 라이선스 + ## 빠른 시작 처음 clone 후 가장 먼저 할 일: @@ -43,6 +51,18 @@ npm run dev Vite가 출력하는 로컬 주소를 브라우저에서 열면 됩니다. 보통은 `http://localhost:5173` 입니다. +Unity 플레이어블 실행: + +```text +unity/MagicExamHall +``` + +1. Unity Hub 또는 Unity Editor에서 위 프로젝트 폴더를 엽니다. +2. `Assets/Scenes/MagicExamHall.unity`를 엽니다. +3. Play를 누릅니다. + +조작은 WASD/방향키 이동, 우클릭 hold/release 월드 드로잉입니다. 기본 플레이에서는 별도 입력 패널이나 `마법 시전` 버튼을 사용하지 않습니다. + ## 초기 검증 튜토리얼 처음 받았을 때는 아래 순서로 확인하면 됩니다. @@ -196,6 +216,14 @@ npm run test:watch # Vitest watch npm run validate:docs # docs 상태/의존성 검증 ``` +Unity 검증 명령 예시: + +```powershell +& 'C:\Program Files\Unity\Hub\Editor\6000.3.14f1\Editor\Unity.com' -batchmode -quit -projectPath 'C:\Users\silve\source\repos\magic\unity\MagicExamHall' -executeMethod MagicExamHall.Editor.MagicExamHallSceneBuilder.BuildAll +& 'C:\Program Files\Unity\Hub\Editor\6000.3.14f1\Editor\Unity.com' -batchmode -projectPath 'C:\Users\silve\source\repos\magic\unity\MagicExamHall' -runTests -testPlatform editmode -testResults 'C:\Users\silve\source\repos\magic\unity\MagicExamHall\TestResults.xml' +& 'C:\Program Files\Unity\Hub\Editor\6000.3.14f1\Editor\Unity.com' -batchmode -projectPath 'C:\Users\silve\source\repos\magic\unity\MagicExamHall' -runTests -testPlatform playmode -testResults 'C:\Users\silve\source\repos\magic\unity\MagicExamHall\PlayModeTestResults.xml' +``` + ## 디렉토리 구조 ```text @@ -204,6 +232,7 @@ tests/ Vitest 테스트 scripts/ 문서 검증 스크립트 docs/ 방향/큐/task 문서 chat/ 원본 논의 보관소 +unity/ Unity Magic Exam Hall 플레이어블 ``` ## 문서 읽기 순서 @@ -230,3 +259,11 @@ npm ci ```bash npm run validate:docs ``` + +Unity batchmode에서 아래 메시지가 보이면 코드 컴파일 실패가 아니라 라이선스 초기화 문제입니다. + +```text +No valid Unity Editor license found. Please activate your license. +``` + +로그 끝의 `abort_threads: Failed aborting id ... mono_thread_manage will ignore it` 메시지는 Unity/Mono 종료 과정에서 남는 노이즈일 수 있습니다. 같은 로그 안에 `Exception`, `CS#### error`, `Test run failed`가 함께 있는지 먼저 확인하세요. diff --git a/unity/MagicExamHall/Assets/MagicExamHall.Runtime.asmdef b/unity/MagicExamHall/Assets/MagicExamHall.Runtime.asmdef new file mode 100644 index 0000000..5941445 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall.Runtime.asmdef @@ -0,0 +1,14 @@ +{ + "name": "MagicExamHall.Runtime", + "rootNamespace": "MagicExamHall", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall.Runtime.asmdef.meta b/unity/MagicExamHall/Assets/MagicExamHall.Runtime.asmdef.meta new file mode 100644 index 0000000..ddcbd24 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1d1c69cf6fe49924b81fbfa17a878f58 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall.meta b/unity/MagicExamHall/Assets/MagicExamHall.meta new file mode 100644 index 0000000..1ecd01b --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3076aa14fbcd85342952f31c5011cc82 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Editor.meta b/unity/MagicExamHall/Assets/MagicExamHall/Editor.meta new file mode 100644 index 0000000..4542297 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b3b4d4e9664ea044afb87381dc37b0b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHall.Editor.asmdef b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHall.Editor.asmdef new file mode 100644 index 0000000..1c9bab1 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHall.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "MagicExamHall.Editor", + "rootNamespace": "MagicExamHall.Editor", + "references": [ + "MagicExamHall.Runtime" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHall.Editor.asmdef.meta b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHall.Editor.asmdef.meta new file mode 100644 index 0000000..21a6aa0 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHall.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 893ec19061978314589c3fff40f49857 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs new file mode 100644 index 0000000..e9ce5b4 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs @@ -0,0 +1,207 @@ +using MagicExamHall; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.SceneManagement; +using UnityEngine.UI; + +namespace MagicExamHall.Editor +{ + public static class MagicExamHallSceneBuilder + { + private const string ScenePath = "Assets/Scenes/MagicExamHall.unity"; + private const string PrefabFolder = "Assets/MagicExamHall/Prefabs"; + private const string ResourcesFolder = "Assets/MagicExamHall/Resources"; + private const string MaterialFolder = "Assets/MagicExamHall/Resources/MagicExamHallMaterials"; + + [MenuItem("Magic Exam Hall/Rebuild Demo Scene")] + public static void BuildAll() + { + EnsureFolders(); + EnsureMaterials(); + + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + scene.name = "MagicExamHall"; + + var camera = CreateCamera(); + CreateFloor(); + var player = CreatePlayer(); + var canvas = CreateCanvas(); + CreateEventSystem(); + CreateController(camera, player.transform, canvas); + SavePrefabs(player); + + EditorSceneManager.MarkSceneDirty(scene); + EditorSceneManager.SaveScene(scene, ScenePath); + EditorBuildSettings.scenes = new[] { new EditorBuildSettingsScene(ScenePath, true) }; + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log($"Magic Exam Hall scene rebuilt at {ScenePath}"); + } + + private static void EnsureFolders() + { + if (!AssetDatabase.IsValidFolder("Assets/Scenes")) + { + AssetDatabase.CreateFolder("Assets", "Scenes"); + } + + if (!AssetDatabase.IsValidFolder("Assets/MagicExamHall")) + { + AssetDatabase.CreateFolder("Assets", "MagicExamHall"); + } + + if (!AssetDatabase.IsValidFolder(PrefabFolder)) + { + AssetDatabase.CreateFolder("Assets/MagicExamHall", "Prefabs"); + } + + if (!AssetDatabase.IsValidFolder(ResourcesFolder)) + { + AssetDatabase.CreateFolder("Assets/MagicExamHall", "Resources"); + } + + if (!AssetDatabase.IsValidFolder(MaterialFolder)) + { + AssetDatabase.CreateFolder(ResourcesFolder, "MagicExamHallMaterials"); + } + } + + private static void EnsureMaterials() + { + CreateOrUpdateMaterial($"{MaterialFolder}/PixelSpriteDefault.mat", "Sprites/Default"); + CreateOrUpdateMaterial($"{MaterialFolder}/PixelUIDefault.mat", "UI/Default"); + } + + private static void CreateOrUpdateMaterial(string path, string shaderName) + { + var shader = Shader.Find(shaderName); + if (shader == null) + { + Debug.LogWarning($"Could not find shader {shaderName}; material {path} was not generated."); + return; + } + + var material = AssetDatabase.LoadAssetAtPath(path); + if (material == null) + { + material = new Material(shader); + AssetDatabase.CreateAsset(material, path); + } + else + { + material.shader = shader; + EditorUtility.SetDirty(material); + } + } + + private static Camera CreateCamera() + { + var cameraObject = new GameObject("Main Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0f, 0f, -10f); + var camera = cameraObject.AddComponent(); + camera.orthographic = true; + camera.orthographicSize = 6.2f; + camera.backgroundColor = new Color(0.06f, 0.08f, 0.11f); + return camera; + } + + private static void CreateFloor() + { + CreatePixelObject("Stone Tile Floor", Vector2.zero, Vector3.one, PixelSpriteKind.FloorTile, + new Color(0.16f, 0.18f, 0.23f), new Color(0.10f, 0.12f, 0.16f), -7, true, new Vector2(16.4f, 10f)); + CreatePixelObject("North Carved Wall", new Vector2(0f, 4.95f), Vector3.one, PixelSpriteKind.WallTrim, + new Color(0.22f, 0.20f, 0.27f), new Color(0.63f, 0.50f, 0.23f), -4, true, new Vector2(16.4f, 1.15f)); + CreatePixelObject("South Carved Wall", new Vector2(0f, -4.95f), Vector3.one, PixelSpriteKind.WallTrim, + new Color(0.18f, 0.17f, 0.22f), new Color(0.50f, 0.40f, 0.20f), -4, true, new Vector2(16.4f, 0.8f)); + CreatePixelObject("Center Runner", new Vector2(0f, 0.15f), Vector3.one, PixelSpriteKind.Rug, + new Color(0.55f, 0.10f, 0.17f), new Color(0.95f, 0.69f, 0.26f), -5, true, new Vector2(2.2f, 7.6f)); + CreatePixelObject("West Runner", new Vector2(-4.25f, 0f), Vector3.one, PixelSpriteKind.Rug, + new Color(0.14f, 0.34f, 0.44f), new Color(0.80f, 0.65f, 0.32f), -5, true, new Vector2(1.45f, 4.8f)); + CreatePixelObject("East Runner", new Vector2(4.25f, 0f), Vector3.one, PixelSpriteKind.Rug, + new Color(0.14f, 0.34f, 0.44f), new Color(0.80f, 0.65f, 0.32f), -5, true, new Vector2(1.45f, 4.8f)); + + CreateProp("West Bookcase", new Vector2(-7.25f, 1.25f), new Vector3(1.25f, 1.25f, 1f), PixelSpriteKind.Bookshelf, + new Color(0.42f, 0.23f, 0.12f), new Color(0.42f, 0.80f, 0.88f), -1); + CreateProp("East Bookcase", new Vector2(7.25f, 1.25f), new Vector3(1.25f, 1.25f, 1f), PixelSpriteKind.Bookshelf, + new Color(0.42f, 0.23f, 0.12f), new Color(0.68f, 0.36f, 0.86f), -1); + CreateProp("Northwest Candelabra", new Vector2(-6.85f, 3.65f), Vector3.one * 0.9f, PixelSpriteKind.Candle, + new Color(0.63f, 0.57f, 0.44f), new Color(1f, 0.56f, 0.15f), 2); + CreateProp("Northeast Candelabra", new Vector2(6.85f, 3.65f), Vector3.one * 0.9f, PixelSpriteKind.Candle, + new Color(0.63f, 0.57f, 0.44f), new Color(1f, 0.56f, 0.15f), 2); + } + + private static GameObject CreateProp(string name, Vector2 position, Vector3 scale, PixelSpriteKind kind, Color primary, Color secondary, int sortingOrder) + { + return CreatePixelObject(name, position, scale, kind, primary, secondary, sortingOrder, false, Vector2.one); + } + + private static GameObject CreatePixelObject(string name, Vector2 position, Vector3 scale, PixelSpriteKind kind, Color primary, Color secondary, int sortingOrder, bool tiled, Vector2 tiledSize) + { + var body = new GameObject(name); + body.transform.position = position; + body.transform.localScale = scale; + body.AddComponent(); + var sprite = body.AddComponent(); + sprite.kind = kind; + sprite.primary = primary; + sprite.secondary = secondary; + sprite.sortingOrder = sortingOrder; + sprite.tiled = tiled; + sprite.tiledSize = tiledSize; + return body; + } + + private static GameObject CreatePlayer() + { + var player = new GameObject("Apprentice"); + player.transform.position = new Vector3(0f, -4.1f, 0f); + player.transform.localScale = Vector3.one * 0.78f; + player.AddComponent(); + var sprite = player.AddComponent(); + sprite.kind = PixelSpriteKind.Player; + sprite.primary = new Color(0.95f, 0.92f, 0.78f); + sprite.secondary = new Color(0.28f, 0.62f, 0.96f); + sprite.sortingOrder = 4; + return player; + } + + private static Canvas CreateCanvas() + { + var canvasObject = new GameObject("Exam Canvas"); + canvasObject.AddComponent(); + var canvas = canvasObject.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + var scaler = canvasObject.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1280, 720); + canvasObject.AddComponent(); + return canvas; + } + + private static void CreateEventSystem() + { + var eventSystem = new GameObject("EventSystem"); + eventSystem.AddComponent(); + eventSystem.AddComponent(); + } + + private static void CreateController(Camera camera, Transform player, Canvas canvas) + { + var controllerObject = new GameObject("Exam Game Controller"); + var controller = controllerObject.AddComponent(); + controller.mainCamera = camera; + controller.player = player; + controller.canvas = canvas; + var drawing = controllerObject.AddComponent(); + drawing.mainCamera = camera; + } + + private static void SavePrefabs(GameObject player) + { + PrefabUtility.SaveAsPrefabAsset(player, $"{PrefabFolder}/Apprentice.prefab"); + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs.meta new file mode 100644 index 0000000..efe35dc --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Editor/MagicExamHallSceneBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04a2ee83da1761740ab66a04f47e9442 \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Prefabs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Prefabs.meta new file mode 100644 index 0000000..53b97ab --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02faf9e0f13a97147af79e55d03f075a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Prefabs/Apprentice.prefab b/unity/MagicExamHall/Assets/MagicExamHall/Prefabs/Apprentice.prefab new file mode 100644 index 0000000..9fb6aee --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Prefabs/Apprentice.prefab @@ -0,0 +1,112 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1525655560304917372 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3883147340807070265} + - component: {fileID: 4011419676409866339} + - component: {fileID: 1263442648826626375} + m_Layer: 0 + m_Name: Apprentice + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3883147340807070265 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1525655560304917372} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: -4.1, z: 0} + m_LocalScale: {x: 0.78, y: 0.78, z: 0.78} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!212 &4011419676409866339 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1525655560304917372} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!114 &1263442648826626375 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1525655560304917372} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 0 + primary: {r: 0.95, g: 0.92, b: 0.78, a: 1} + secondary: {r: 0.28, g: 0.62, b: 0.96, a: 1} + sortingOrder: 4 + tiled: 0 + tiledSize: {x: 1, y: 1} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Prefabs/Apprentice.prefab.meta b/unity/MagicExamHall/Assets/MagicExamHall/Prefabs/Apprentice.prefab.meta new file mode 100644 index 0000000..90c6353 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Prefabs/Apprentice.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f307b484f17f6424db08641286dfd52f +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Resources.meta b/unity/MagicExamHall/Assets/MagicExamHall/Resources.meta new file mode 100644 index 0000000..0ab03a0 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5cef4dd7a2d1fcf489b34a7ef5d4d9d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials.meta b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials.meta new file mode 100644 index 0000000..650b5bd --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a37ec78d6ec075049a2ef7d307b20331 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelSpriteDefault.mat b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelSpriteDefault.mat new file mode 100644 index 0000000..f540dbc --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelSpriteDefault.mat @@ -0,0 +1,43 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: PixelSpriteDefault + m_Shader: {fileID: 10753, guid: 0000000000000000f000000000000000, type: 0} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _AlphaTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - PixelSnap: 0 + - _EnableExternalAlpha: 0 + m_Colors: + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _Flip: {r: 1, g: 1, b: 1, a: 1} + - _RendererColor: {r: 1, g: 1, b: 1, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelSpriteDefault.mat.meta b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelSpriteDefault.mat.meta new file mode 100644 index 0000000..e413c8b --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelSpriteDefault.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fa7abae12d5ed5440b33bdbf71b7b4b2 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelUIDefault.mat b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelUIDefault.mat new file mode 100644 index 0000000..82f8a33 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelUIDefault.mat @@ -0,0 +1,42 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: PixelUIDefault + m_Shader: {fileID: 10770, guid: 0000000000000000f000000000000000, type: 0} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _ColorMask: 15 + - _Stencil: 0 + - _StencilComp: 8 + - _StencilOp: 0 + - _StencilReadMask: 255 + - _StencilWriteMask: 255 + - _UseUIAlphaClip: 0 + m_Colors: + - _Color: {r: 1, g: 1, b: 1, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelUIDefault.mat.meta b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelUIDefault.mat.meta new file mode 100644 index 0000000..00683b0 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Resources/MagicExamHallMaterials/PixelUIDefault.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 706deea943b6451459a1687783fe096c +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts.meta new file mode 100644 index 0000000..979e9c0 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 27596197ec75a4e4cb02542fa1f4956c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core.meta new file mode 100644 index 0000000..c8eb064 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 897c67b131e6b224cb4c2081d4ee8b3a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/ExamLogging.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/ExamLogging.cs new file mode 100644 index 0000000..8895616 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/ExamLogging.cs @@ -0,0 +1,159 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; + +namespace MagicExamHall +{ + [Serializable] + public sealed class AttemptLog + { + public string sessionId = ""; + public string trialId = ""; + public string targetFamily = ""; + public string recognizedFamily = ""; + public string phase = ""; + public string baseFamily = ""; + public string overlayStack = ""; + public string sealId = ""; + public string floorId = ""; + public string targetObject = ""; + public string worldEffect = ""; + public string status = ""; + public float confidence; + public float closure; + public float smoothness; + public float tempo; + public float stability; + public float rotationBias; + public float worldX; + public float worldY; + public int bufferStrokeCount; + public int attemptIndex; + public int elapsedMs; + public bool feedbackViewed; + public bool success; + public bool hintShown; + public int assistLevel; + public bool assisted; + } + + [Serializable] + public sealed class SurveyLog + { + public string sessionId = ""; + public int clarity; + public int fairness; + public int feedbackHelpfulness; + public int controlFeeling; + public int immersion; + public string comment = ""; + public int completedTrials; + public int totalAttempts; + } + + public sealed class ExamLogger + { + public string OutputDirectory { get; } + private readonly string attemptsJsonPath; + private readonly string attemptsCsvPath; + private readonly string surveyJsonPath; + private readonly string surveyCsvPath; + + public ExamLogger(string sessionId) + { + OutputDirectory = Path.Combine(Application.persistentDataPath, "MagicExamHallLogs", sessionId); + Directory.CreateDirectory(OutputDirectory); + attemptsJsonPath = Path.Combine(OutputDirectory, "attempts.jsonl"); + attemptsCsvPath = Path.Combine(OutputDirectory, "attempts.csv"); + surveyJsonPath = Path.Combine(OutputDirectory, "survey.jsonl"); + surveyCsvPath = Path.Combine(OutputDirectory, "survey.csv"); + EnsureAttemptHeader(); + EnsureSurveyHeader(); + } + + public void LogAttempt(AttemptLog log) + { + File.AppendAllText(attemptsJsonPath, JsonUtility.ToJson(log) + Environment.NewLine, Encoding.UTF8); + File.AppendAllText(attemptsCsvPath, string.Join(",", + Csv(log.sessionId), + Csv(log.trialId), + Csv(log.targetFamily), + Csv(log.recognizedFamily), + Csv(log.phase), + Csv(log.baseFamily), + Csv(log.overlayStack), + Csv(log.sealId), + Csv(log.floorId), + Csv(log.targetObject), + Csv(log.worldEffect), + Csv(log.status), + Float(log.confidence), + Float(log.closure), + Float(log.smoothness), + Float(log.tempo), + Float(log.stability), + Float(log.rotationBias), + Float(log.worldX), + Float(log.worldY), + log.bufferStrokeCount, + log.attemptIndex, + log.elapsedMs, + Bool(log.feedbackViewed), + Bool(log.success), + Bool(log.hintShown), + log.assistLevel, + Bool(log.assisted)) + Environment.NewLine, Encoding.UTF8); + } + + public void LogSurvey(SurveyLog log) + { + File.AppendAllText(surveyJsonPath, JsonUtility.ToJson(log) + Environment.NewLine, Encoding.UTF8); + File.AppendAllText(surveyCsvPath, string.Join(",", + Csv(log.sessionId), + log.clarity, + log.fairness, + log.feedbackHelpfulness, + log.controlFeeling, + log.immersion, + Csv(log.comment), + log.completedTrials, + log.totalAttempts) + Environment.NewLine, Encoding.UTF8); + } + + private void EnsureAttemptHeader() + { + if (!File.Exists(attemptsCsvPath)) + { + File.WriteAllText( + attemptsCsvPath, + "sessionId,trialId,targetFamily,recognizedFamily,phase,baseFamily,overlayStack,sealId,floorId,targetObject,worldEffect,status,confidence,closure,smoothness,tempo,stability,rotationBias,worldX,worldY,bufferStrokeCount,attemptIndex,elapsedMs,feedbackViewed,success,hintShown,assistLevel,assisted" + Environment.NewLine, + Encoding.UTF8); + } + } + + private void EnsureSurveyHeader() + { + if (!File.Exists(surveyCsvPath)) + { + File.WriteAllText(surveyCsvPath, "sessionId,clarity,fairness,feedbackHelpfulness,controlFeeling,immersion,comment,completedTrials,totalAttempts" + Environment.NewLine, Encoding.UTF8); + } + } + + private static string Csv(string value) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + + private static string Float(float value) + { + return value.ToString("0.###", CultureInfo.InvariantCulture); + } + + private static string Bool(bool value) + { + return value ? "true" : "false"; + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/ExamLogging.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/ExamLogging.cs.meta new file mode 100644 index 0000000..2cb4358 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/ExamLogging.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 137df44cf55179d4fbac7bfadf6d1d39 \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs new file mode 100644 index 0000000..5c528c6 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; + +namespace MagicExamHall +{ + public enum AssistLevel + { + None = 0, + ReasonHint = 1, + Checklist = 2, + GhostTrace = 3 + } + + [Serializable] + public sealed class HintState + { + public SpellFamily family; + public int failureCount; + public AssistLevel currentLevel; + public bool hintShown; + public bool assisted; + public string title = ""; + public string body = ""; + + public int AssistLevelNumber => (int)currentLevel; + } + + public static class HintAssistance + { + public static HintState PreviewFor(SpellFamily family, int priorFailures, SpellResult result = null) + { + var level = (AssistLevel)Math.Min(Math.Max(priorFailures, 0), 3); + return CreateState(family, priorFailures, level, priorFailures > 0, result); + } + + public static HintState ForAttempt(SpellFamily family, int priorFailures, bool success, SpellResult result) + { + var level = ResolveLevel(priorFailures, success); + return CreateState(family, priorFailures, level, priorFailures > 0, result); + } + + public static AssistLevel ResolveLevel(int priorFailures, bool success) + { + if (success) + { + if (priorFailures <= 0) + { + return AssistLevel.None; + } + + return (AssistLevel)Math.Min(priorFailures, 3); + } + + return (AssistLevel)Math.Min(Math.Max(priorFailures + 1, 1), 3); + } + + public static IReadOnlyList ChecklistFor(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => new[] { "꼭짓점 3개가 보이게 그리기", "마지막 점을 시작점 근처로 닫기", "삼각형을 위쪽으로 세우기" }, + SpellFamily.Water => new[] { "한 획으로 둥글게 이어 그리기", "끝점을 시작점 근처로 닫기", "찌그러짐보다 큰 원형 흐름 유지" }, + SpellFamily.Wind => new[] { "짧은 선 3개를 따로 그리기", "세 선을 비슷한 방향으로 놓기", "너무 닫힌 도형처럼 만들지 않기" }, + SpellFamily.Earth => new[] { "사다리꼴 네 모서리 만들기", "아래 변을 더 넓게 그리기", "끝점을 닫아 안정감 만들기" }, + SpellFamily.Life => new[] { "아래 줄기에서 위로 올라가기", "상단에서 좌우 가지 만들기", "닫힌 도형보다 열린 Y 형태 유지" }, + _ => Array.Empty() + }; + } + + private static string TitleFor(AssistLevel level) + { + return level switch + { + AssistLevel.None => "자율 입력", + AssistLevel.ReasonHint => "짧은 힌트", + AssistLevel.Checklist => "체크리스트", + AssistLevel.GhostTrace => "강한 보조선", + _ => "힌트" + }; + } + + private static string BodyFor(SpellFamily family, AssistLevel level, SpellResult result) + { + if (level == AssistLevel.None) + { + return $"{SpellLabels.Korean(family)} 문양을 먼저 스스로 읽히게 해 보세요."; + } + + if (level == AssistLevel.ReasonHint) + { + return result == null ? ActionHintFor(family) : result.nextHint; + } + + if (level == AssistLevel.Checklist) + { + return string.Join(" · ", ChecklistFor(family)); + } + + return StrongHintFor(family); + } + + private static HintState CreateState(SpellFamily family, int priorFailures, AssistLevel level, bool assisted, SpellResult result) + { + return new HintState + { + family = family, + failureCount = Math.Max(priorFailures, 0), + currentLevel = level, + hintShown = level != AssistLevel.None, + assisted = assisted, + title = TitleFor(level), + body = BodyFor(family, level, result) + }; + } + + private static string ActionHintFor(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => "삼각형 꼭짓점 3개를 크게 잡고 마지막 점을 시작점 근처로 닫아 보세요.", + SpellFamily.Water => "한 획으로 둥글게 돌린 뒤 끝점을 시작점 가까이에 놓아 보세요.", + SpellFamily.Wind => "짧은 평행선 3개를 서로 비슷한 간격으로 따로 그려 보세요.", + SpellFamily.Earth => "윗변이 좁고 아랫변이 넓은 사다리꼴을 닫힌 모양으로 그려 보세요.", + SpellFamily.Life => "아래 줄기에서 올라와 좌우 가지로 갈라지는 열린 Y 형태를 만들어 보세요.", + _ => "큰 실루엣을 먼저 맞추고 세부 속도는 나중에 조정하세요." + }; + } + + private static string StrongHintFor(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => "불꽃은 닫힌 삼각형입니다. 아래 꼭짓점에서 시작해 위 양쪽 꼭짓점을 찍고 처음으로 돌아오세요.", + SpellFamily.Water => "물은 닫힌 원입니다. 한 번에 둥글게 돌리고 끝점을 시작점 바로 옆에 놓으세요.", + SpellFamily.Wind => "바람은 도형이 아니라 세 줄입니다. 같은 방향의 짧은 선 3개만 남기세요.", + SpellFamily.Earth => "땅은 닫힌 사다리꼴입니다. 네 모서리를 만들고 마지막 선으로 틈을 막으세요.", + SpellFamily.Life => "생명은 열린 가지입니다. 줄기 하나와 좌우 가지를 만들고 원처럼 닫지 마세요.", + _ => "문양을 더 크게, 더 단순하게 그린 뒤 다시 시전하세요." + }; + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs.meta new file mode 100644 index 0000000..6a899ed --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/HintAssistance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4a802bdb982ce040bad9accd73b826e \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs new file mode 100644 index 0000000..9555ce0 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs @@ -0,0 +1,814 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace MagicExamHall +{ + public enum SpellFamily + { + Wind, + Earth, + Fire, + Water, + Life + } + + public enum RecognitionStatus + { + Recognized, + Ambiguous, + Incomplete, + Invalid + } + + [Serializable] + public struct StrokeSample + { + public Vector2 position; + public float time; + + public StrokeSample(Vector2 position, float time) + { + this.position = position; + this.time = time; + } + } + + [Serializable] + public struct QualityVector + { + public float closure; + public float smoothness; + public float tempo; + public float stability; + public float rotationBias; + + public float Average() + { + return (closure + smoothness + tempo + stability + (1f - rotationBias)) / 5f; + } + } + + [Serializable] + public sealed class SpellResult + { + public RecognitionStatus status; + public SpellFamily? recognizedFamily; + public SpellFamily targetFamily; + public float confidence; + public QualityVector quality; + public string feedbackReason; + public string nextHint; + public bool success; + + public string RecognizedFamilyText => recognizedFamily.HasValue ? SpellLabels.English(recognizedFamily.Value) : "none"; + } + + internal sealed class SpellTemplate + { + public SpellFamily family; + public int minStrokes; + public int maxStrokes; + public List> strokes = new(); + public List normalizedCloud = new(); + } + + public static class GestureRecognizer + { + private const int CloudPointCount = 64; + private static readonly IReadOnlyList Templates = BuildTemplates(); + + public static SpellResult Recognize(IReadOnlyList> rawStrokes, SpellFamily targetFamily) + { + var drawable = rawStrokes + .Select(stroke => stroke.Where(point => IsFinite(point.position)).ToList()) + .Where(stroke => stroke.Count >= 2) + .ToList(); + + if (drawable.Count == 0) + { + return new SpellResult + { + status = RecognitionStatus.Invalid, + targetFamily = targetFamily, + confidence = 0f, + quality = new QualityVector(), + feedbackReason = "No stroke was captured.", + nextHint = "Hold right mouse on the map floor and draw the spell." + }; + } + + var quality = QualityAnalyzer.Calculate(drawable); + var normalized = NormalizeStrokes(drawable.Select(stroke => stroke.Select(sample => sample.position).ToList()).ToList()); + var scored = Templates + .Select(template => ScoreTemplate(template, normalized, drawable, quality)) + .OrderByDescending(candidate => candidate.score) + .ToList(); + + var top = scored[0]; + var second = scored.Count > 1 ? scored[1] : top; + var margin = top.score - second.score; + var status = ResolveStatus(top, margin, quality, drawable.Count); + if (targetFamily == SpellFamily.Wind && drawable.Count < 3) + { + status = RecognitionStatus.Incomplete; + } + else if (RequiresClosure(targetFamily) && quality.closure < 0.62f) + { + status = RecognitionStatus.Incomplete; + } + SpellFamily? recognized = status == RecognitionStatus.Recognized ? top.template.family : null; + var success = recognized.HasValue && recognized.Value == targetFamily; + + return new SpellResult + { + status = status, + targetFamily = targetFamily, + recognizedFamily = recognized, + confidence = Mathf.Clamp01(top.score), + quality = quality, + success = success, + feedbackReason = BuildReason(targetFamily, top, second, status, quality), + nextHint = BuildHint(targetFamily, top, status, quality, drawable.Count) + }; + } + + public static List> CreateCanonicalSamples(SpellFamily family, float scale = 420f, float timeStep = 0.04f) + { + var template = Templates.First(item => item.family == family); + var result = new List>(); + var time = 0f; + + foreach (var stroke in template.strokes) + { + var mapped = new List(); + foreach (var point in stroke) + { + mapped.Add(new StrokeSample(new Vector2(point.x * scale + scale * 0.5f, point.y * scale + scale * 0.5f), time)); + time += timeStep; + } + result.Add(mapped); + time += timeStep * 4f; + } + + return result; + } + + private static RecognitionStatus ResolveStatus( + (SpellTemplate template, float score, float distance) top, + float margin, + QualityVector quality, + int strokeCount) + { + if (top.template.family == SpellFamily.Wind && strokeCount < 3 && top.score >= 0.42f) + { + return RecognitionStatus.Incomplete; + } + + if (RequiresClosure(top.template.family) && quality.closure < 0.62f && top.score >= 0.42f) + { + return RecognitionStatus.Incomplete; + } + + if (top.score >= 0.70f && margin >= 0.08f) + { + return RecognitionStatus.Recognized; + } + + if (top.score >= 0.54f) + { + return RecognitionStatus.Ambiguous; + } + + return RecognitionStatus.Invalid; + } + + private static (SpellTemplate template, float score, float distance) ScoreTemplate( + SpellTemplate template, + NormalizedGesture normalized, + List> strokes, + QualityVector quality) + { + var distance = PointCloudDistance(normalized.cloud, template.normalizedCloud); + var templateScore = Mathf.Clamp01(1f - distance / 0.66f); + var strokeScore = RangeScore(strokes.Count, template.minStrokes, template.maxStrokes); + var openness = 1f - quality.closure; + var cornerCount = CountDominantCorners(strokes); + var corners = ExpectedCornerScore(cornerCount, ExpectedCorners(template.family)); + var circularity = EstimateCircularity(normalized.cloud); + var parallel = EstimateParallelism(strokes); + var fill = EstimateFillRatio(strokes); + var score = templateScore; + + switch (template.family) + { + case SpellFamily.Wind: + score = templateScore * 0.42f + parallel * 0.30f + strokeScore * 0.20f + openness * 0.08f; + if (strokes.Count < 3) + { + score *= 0.55f; + } + break; + case SpellFamily.Earth: + score = templateScore * 0.40f + quality.closure * 0.23f + corners * 0.20f + Closeness(fill, 0.68f, 0.24f) * 0.09f + strokeScore * 0.08f; + break; + case SpellFamily.Fire: + score = templateScore * 0.42f + quality.closure * 0.24f + corners * 0.21f + Closeness(fill, 0.5f, 0.18f) * 0.07f + strokeScore * 0.06f; + break; + case SpellFamily.Water: + score = templateScore * 0.48f + quality.closure * 0.19f + circularity * 0.22f + quality.smoothness * 0.11f; + break; + case SpellFamily.Life: + score = templateScore * 0.36f + ExpectedEndpointScore(strokes) * 0.28f + openness * 0.18f + strokeScore * 0.10f + Closeness(fill, 0.16f, 0.20f) * 0.08f; + break; + } + + return (template, Mathf.Clamp01(score), distance); + } + + private static string BuildReason( + SpellFamily target, + (SpellTemplate template, float score, float distance) top, + (SpellTemplate template, float score, float distance) second, + RecognitionStatus status, + QualityVector quality) + { + if (RequiresClosure(target) && quality.closure < 0.62f) + { + return "닫힌 문양의 끝점이 충분히 맞닿지 않아 미완성으로 남았습니다."; + } + + if (target == SpellFamily.Wind && status == RecognitionStatus.Incomplete) + { + return "바람 문양은 평행한 선 3개가 필요합니다."; + } + + if (status == RecognitionStatus.Recognized && top.template.family == target) + { + return $"{SpellLabels.Korean(target)} 문양으로 안정적으로 읽혔습니다."; + } + + if (status == RecognitionStatus.Recognized) + { + return $"{SpellLabels.Korean(top.template.family)} 문양에 더 가까워 목표 문양과 다르게 처리되었습니다."; + } + + if (status == RecognitionStatus.Incomplete && RequiresClosure(top.template.family) && quality.closure < 0.62f) + { + return "닫힌 문양의 끝점이 충분히 맞닿지 않아 미완성으로 남았습니다."; + } + + if (status == RecognitionStatus.Incomplete && top.template.family == SpellFamily.Wind) + { + return "바람 문양은 평행한 선 3개가 필요합니다."; + } + + if (status == RecognitionStatus.Ambiguous) + { + return $"{SpellLabels.Korean(top.template.family)}와 {SpellLabels.Korean(second.template.family)} 점수가 가까워 확정하지 않았습니다."; + } + + return "기준 문양과의 거리가 커서 마법을 확정하지 않았습니다."; + } + + private static string BuildHint( + SpellFamily target, + (SpellTemplate template, float score, float distance) top, + RecognitionStatus status, + QualityVector quality, + int strokeCount) + { + if (target == SpellFamily.Wind && strokeCount < 3) + { + return "짧은 평행선을 3획으로 나누어 그려 보세요."; + } + + if (RequiresClosure(target) && quality.closure < 0.72f) + { + return "마지막 점을 시작점 근처로 가져와 닫힌 모양을 만들어 보세요."; + } + + if (quality.rotationBias > 0.55f) + { + return "문양을 조금 더 정면 방향으로 세워 그리면 안정도가 올라갑니다."; + } + + if (status == RecognitionStatus.Recognized && top.template.family == target) + { + return "좋습니다. 같은 문양을 유지하면 다음 시험으로 넘어갈 수 있습니다."; + } + + return $"{SpellLabels.Korean(target)}의 큰 실루엣을 먼저 맞추고 세부 속도는 나중에 조정하세요."; + } + + private static IReadOnlyList BuildTemplates() + { + var templates = new List + { + new() + { + family = SpellFamily.Wind, + minStrokes = 3, + maxStrokes = 3, + strokes = new List> + { + Line(-0.45f, -0.20f, 0.45f, -0.23f, 16), + Line(-0.45f, 0.00f, 0.45f, -0.03f, 16), + Line(-0.45f, 0.20f, 0.45f, 0.17f, 16) + } + }, + new() + { + family = SpellFamily.Earth, + minStrokes = 1, + maxStrokes = 2, + strokes = new List> + { + Poly(new Vector2(-0.25f, -0.38f), new Vector2(0.25f, -0.38f), new Vector2(0.46f, 0.34f), new Vector2(-0.46f, 0.34f), new Vector2(-0.25f, -0.38f)) + } + }, + new() + { + family = SpellFamily.Fire, + minStrokes = 1, + maxStrokes = 2, + strokes = new List> + { + Poly(new Vector2(0f, -0.46f), new Vector2(0.46f, 0.42f), new Vector2(-0.46f, 0.42f), new Vector2(0f, -0.46f)) + } + }, + new() + { + family = SpellFamily.Water, + minStrokes = 1, + maxStrokes = 1, + strokes = new List> { Ellipse(0.44f, 0.39f, 72) } + }, + new() + { + family = SpellFamily.Life, + minStrokes = 1, + maxStrokes = 3, + strokes = new List> + { + Poly(new Vector2(0f, 0.46f), new Vector2(0f, 0.08f), new Vector2(-0.34f, -0.30f)), + Line(0f, 0.08f, 0.34f, -0.30f, 12) + } + } + }; + + foreach (var template in templates) + { + template.normalizedCloud = NormalizeStrokes(template.strokes).cloud; + } + + return templates; + } + + private static bool RequiresClosure(SpellFamily family) + { + return family == SpellFamily.Earth || family == SpellFamily.Fire || family == SpellFamily.Water; + } + + private static int ExpectedCorners(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => 3, + SpellFamily.Earth => 4, + SpellFamily.Life => 4, + _ => 1 + }; + } + + private static NormalizedGesture NormalizeStrokes(IReadOnlyList> strokes) + { + var points = strokes.SelectMany(stroke => stroke).ToList(); + if (points.Count == 0) + { + return new NormalizedGesture(new List(), 1f, Vector2.zero); + } + + var min = new Vector2(points.Min(point => point.x), points.Min(point => point.y)); + var max = new Vector2(points.Max(point => point.x), points.Max(point => point.y)); + var center = (min + max) * 0.5f; + var scale = Mathf.Max(max.x - min.x, max.y - min.y, 0.001f); + var normalizedStrokes = strokes + .Select(stroke => stroke.Select(point => (point - center) / scale).ToList()) + .ToList(); + var cloud = Resample(normalizedStrokes.SelectMany(stroke => stroke).ToList(), CloudPointCount); + return new NormalizedGesture(cloud, scale, center); + } + + private static List Resample(IReadOnlyList points, int count) + { + if (points.Count == 0) + { + return new List(); + } + + if (points.Count == 1) + { + return Enumerable.Repeat(points[0], count).ToList(); + } + + var total = PathLength(points); + var interval = total / Mathf.Max(count - 1, 1); + var output = new List { points[0] }; + var distanceSinceLast = 0f; + var previous = points[0]; + + for (var index = 1; index < points.Count; index++) + { + var current = points[index]; + var segment = Vector2.Distance(previous, current); + if (segment <= 0.0001f) + { + continue; + } + + while (distanceSinceLast + segment >= interval && output.Count < count) + { + var t = (interval - distanceSinceLast) / segment; + var inserted = Vector2.Lerp(previous, current, t); + output.Add(inserted); + segment -= interval - distanceSinceLast; + previous = inserted; + distanceSinceLast = 0f; + } + + distanceSinceLast += segment; + previous = current; + } + + while (output.Count < count) + { + output.Add(points[^1]); + } + + return output; + } + + private static float PointCloudDistance(IReadOnlyList left, IReadOnlyList right) + { + if (left.Count == 0 || right.Count == 0) + { + return 1f; + } + + var count = Mathf.Min(left.Count, right.Count); + var forward = 0f; + var reverse = 0f; + + for (var index = 0; index < count; index++) + { + forward += Vector2.Distance(left[index], right[index]); + reverse += Vector2.Distance(left[index], right[count - 1 - index]); + } + + return Mathf.Min(forward, reverse) / count; + } + + private static float EstimateParallelism(List> strokes) + { + var angles = strokes + .Where(stroke => stroke.Count >= 2) + .Select(stroke => Mathf.Atan2(stroke[^1].position.y - stroke[0].position.y, stroke[^1].position.x - stroke[0].position.x)) + .ToList(); + + if (angles.Count == 0) + { + return 0f; + } + + var x = angles.Sum(angle => Mathf.Cos(angle * 2f)); + var y = angles.Sum(angle => Mathf.Sin(angle * 2f)); + var mean = Mathf.Atan2(y, x) * 0.5f; + var deviation = angles.Average(angle => Mathf.Abs(NormalizeHalfPi(angle - mean))); + return Mathf.Clamp01(1f - deviation / (Mathf.PI / 6f)); + } + + private static int CountDominantCorners(List> strokes) + { + var dominant = strokes.OrderByDescending(stroke => StrokePathLength(stroke)).FirstOrDefault(); + if (dominant == null || dominant.Count < 3) + { + return 0; + } + + var corners = 0; + for (var index = 1; index < dominant.Count - 1; index++) + { + var a = (dominant[index].position - dominant[index - 1].position).normalized; + var b = (dominant[index + 1].position - dominant[index].position).normalized; + if (Vector2.Angle(a, b) > 38f) + { + corners++; + } + } + + return Mathf.Clamp(corners, 0, 6); + } + + private static float ExpectedCornerScore(int actual, int expected) + { + return Mathf.Clamp01(1f - Mathf.Abs(actual - expected) / Mathf.Max(expected, 1f)); + } + + private static float ExpectedEndpointScore(List> strokes) + { + var endpoints = strokes.SelectMany(stroke => new[] { stroke[0].position, stroke[^1].position }).ToList(); + if (endpoints.Count < 3) + { + return 0.35f; + } + + return Mathf.Clamp01(1f - Mathf.Abs(endpoints.Count - 4) / 5f); + } + + private static float EstimateFillRatio(List> strokes) + { + var points = strokes.SelectMany(stroke => stroke.Select(sample => sample.position)).ToList(); + if (points.Count < 3) + { + return 0f; + } + + var minX = points.Min(point => point.x); + var maxX = points.Max(point => point.x); + var minY = points.Min(point => point.y); + var maxY = points.Max(point => point.y); + var boxArea = Mathf.Max((maxX - minX) * (maxY - minY), 1f); + var dominant = strokes.OrderByDescending(stroke => StrokePathLength(stroke)).First(); + var area = Mathf.Abs(PolygonArea(dominant.Select(sample => sample.position).ToList())); + return Mathf.Clamp01(area / boxArea); + } + + private static float EstimateCircularity(IReadOnlyList cloud) + { + if (cloud.Count == 0) + { + return 0f; + } + + var radii = cloud.Select(point => point.magnitude).ToList(); + var mean = radii.Average(); + var variance = radii.Average(radius => Mathf.Pow(radius - mean, 2f)); + return Mathf.Clamp01(1f - Mathf.Sqrt(variance) / Mathf.Max(mean * 0.45f, 0.0001f)); + } + + private static float RangeScore(int value, int min, int max) + { + if (value >= min && value <= max) + { + return 1f; + } + + var distance = value < min ? min - value : value - max; + return Mathf.Clamp01(1f - distance * 0.35f); + } + + private static float Closeness(float value, float expected, float tolerance) + { + return Mathf.Clamp01(1f - Mathf.Abs(value - expected) / Mathf.Max(tolerance, 0.001f)); + } + + private static float StrokePathLength(IReadOnlyList stroke) + { + var points = stroke.Select(sample => sample.position).ToList(); + return PathLength(points); + } + + private static float PathLength(IReadOnlyList points) + { + var length = 0f; + for (var index = 1; index < points.Count; index++) + { + length += Vector2.Distance(points[index - 1], points[index]); + } + + return length; + } + + private static float PolygonArea(IReadOnlyList points) + { + if (points.Count < 3) + { + return 0f; + } + + var sum = 0f; + for (var index = 0; index < points.Count; index++) + { + var current = points[index]; + var next = points[(index + 1) % points.Count]; + sum += current.x * next.y - next.x * current.y; + } + + return sum * 0.5f; + } + + private static float NormalizeHalfPi(float angle) + { + while (angle > Mathf.PI / 2f) + { + angle -= Mathf.PI; + } + + while (angle < -Mathf.PI / 2f) + { + angle += Mathf.PI; + } + + return angle; + } + + private static List Line(float x1, float y1, float x2, float y2, int count) + { + return Enumerable.Range(0, count) + .Select(index => Vector2.Lerp(new Vector2(x1, y1), new Vector2(x2, y2), index / Mathf.Max(count - 1f, 1f))) + .ToList(); + } + + private static List Poly(params Vector2[] anchors) + { + var points = new List(); + for (var index = 1; index < anchors.Length; index++) + { + var line = Line(anchors[index - 1].x, anchors[index - 1].y, anchors[index].x, anchors[index].y, 16); + if (points.Count > 0) + { + line.RemoveAt(0); + } + + points.AddRange(line); + } + + return points; + } + + private static List Ellipse(float radiusX, float radiusY, int count) + { + return Enumerable.Range(0, count) + .Select(index => + { + var angle = (index / (float)(count - 1)) * Mathf.PI * 2f; + return new Vector2(Mathf.Cos(angle) * radiusX, Mathf.Sin(angle) * radiusY); + }) + .ToList(); + } + + private static bool IsFinite(Vector2 value) + { + return float.IsFinite(value.x) && float.IsFinite(value.y); + } + + private readonly struct NormalizedGesture + { + public readonly List cloud; + public readonly float scale; + public readonly Vector2 center; + + public NormalizedGesture(List cloud, float scale, Vector2 center) + { + this.cloud = cloud; + this.scale = scale; + this.center = center; + } + } + } + + public static class QualityAnalyzer + { + public static QualityVector Calculate(IReadOnlyList> strokes) + { + var all = strokes.SelectMany(stroke => stroke).ToList(); + if (all.Count == 0) + { + return new QualityVector(); + } + + var min = new Vector2(all.Min(sample => sample.position.x), all.Min(sample => sample.position.y)); + var max = new Vector2(all.Max(sample => sample.position.x), all.Max(sample => sample.position.y)); + var diagonal = Mathf.Max(Vector2.Distance(min, max), 1f); + var longest = strokes.OrderByDescending(PathLength).First(); + var gap = Vector2.Distance(longest[0].position, longest[^1].position); + var duration = Mathf.Max(all.Max(sample => sample.time) - all.Min(sample => sample.time), 0.001f); + + return new QualityVector + { + closure = Mathf.Clamp01(1f - gap / (diagonal * 0.36f)), + smoothness = CalculateSmoothness(strokes, diagonal), + tempo = Mathf.Clamp01(1f - duration / 1.55f), + stability = CalculateStability(strokes), + rotationBias = CalculateRotationBias(all.Select(sample => sample.position).ToList()) + }; + } + + private static float CalculateSmoothness(IReadOnlyList> strokes, float diagonal) + { + var penalties = new List(); + foreach (var stroke in strokes.Where(stroke => stroke.Count >= 3)) + { + for (var index = 1; index < stroke.Count - 1; index++) + { + var a = Mathf.Atan2(stroke[index].position.y - stroke[index - 1].position.y, stroke[index].position.x - stroke[index - 1].position.x); + var b = Mathf.Atan2(stroke[index + 1].position.y - stroke[index].position.y, stroke[index + 1].position.x - stroke[index].position.x); + penalties.Add(Mathf.Abs(NormalizeHalfPi(b - a)) / Mathf.PI); + } + } + + if (penalties.Count == 0) + { + return 0.5f; + } + + return Mathf.Clamp01(1f - penalties.Average() - diagonal * 0.0005f); + } + + private static float CalculateStability(IReadOnlyList> strokes) + { + var speeds = new List(); + var pauses = 0; + var segments = 0; + + foreach (var stroke in strokes) + { + for (var index = 1; index < stroke.Count; index++) + { + var dt = Mathf.Max(stroke[index].time - stroke[index - 1].time, 0.001f); + speeds.Add(Vector2.Distance(stroke[index - 1].position, stroke[index].position) / dt); + if (dt > 0.35f) + { + pauses++; + } + + segments++; + } + } + + if (speeds.Count == 0) + { + return 0f; + } + + var mean = speeds.Average(); + var variance = speeds.Average(speed => Mathf.Pow(speed - mean, 2f)); + var coefficient = mean > 0f ? Mathf.Sqrt(variance) / mean : 1f; + var pauseRatio = segments > 0 ? pauses / (float)segments : 0f; + return Mathf.Clamp01(1f - Mathf.Clamp01(coefficient * 0.55f + pauseRatio * 0.45f)); + } + + private static float CalculateRotationBias(IReadOnlyList points) + { + if (points.Count < 2) + { + return 0f; + } + + var center = new Vector2(points.Average(point => point.x), points.Average(point => point.y)); + var xx = 0f; + var yy = 0f; + var xy = 0f; + + foreach (var point in points) + { + var delta = point - center; + xx += delta.x * delta.x; + yy += delta.y * delta.y; + xy += delta.x * delta.y; + } + + var angle = 0.5f * Mathf.Atan2(2f * xy, xx - yy); + return Mathf.Clamp01(Mathf.Abs(NormalizeHalfPi(angle)) / (Mathf.PI / 2f)); + } + + private static float PathLength(IReadOnlyList stroke) + { + var length = 0f; + for (var index = 1; index < stroke.Count; index++) + { + length += Vector2.Distance(stroke[index - 1].position, stroke[index].position); + } + + return length; + } + + private static float NormalizeHalfPi(float angle) + { + while (angle > Mathf.PI / 2f) + { + angle -= Mathf.PI; + } + + while (angle < -Mathf.PI / 2f) + { + angle += Mathf.PI; + } + + return angle; + } + } + +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs.meta new file mode 100644 index 0000000..cc53254 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRecognition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f6ff80b6391af445a8cd2643e83c6ad \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs new file mode 100644 index 0000000..6e45f65 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs @@ -0,0 +1,697 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace MagicExamHall +{ + public enum SpellPhase + { + Base, + Overlay, + Final + } + + public enum OverlayOperator + { + SteelBrace, + ElectricFork, + IceBar, + SoulDot, + VoidCut, + MartialAxis + } + + [Serializable] + public sealed class OverlayRecognitionResult + { + public RecognitionStatus status; + public OverlayOperator? recognizedOperator; + public float score; + public float shapeConfidence; + public float scaleRatio; + public string anchorZone = ""; + public string feedbackReason = ""; + public bool success => status == RecognitionStatus.Recognized && recognizedOperator.HasValue; + + public string OperatorText => recognizedOperator.HasValue ? SpellLabels.English(recognizedOperator.Value) : "none"; + } + + [Serializable] + public sealed class CompiledSeal + { + public string sealId = ""; + public SpellFamily baseFamily; + public readonly List overlayStack = new(); + public QualityVector quality; + public Vector2 worldCenter; + public float worldScale = 1f; + public float createdAt; + public float expiresAt; + + public string Label + { + get + { + if (overlayStack.Count == 0) + { + return SpellLabels.Korean(baseFamily); + } + + return $"{SpellLabels.Korean(baseFamily)} + {string.Join(" + ", overlayStack.Select(SpellLabels.Korean))}"; + } + } + } + + public sealed class BaseRecognitionResult + { + public SpellResult spell = null!; + public Vector2 center; + public float worldScale = 1f; + public int bufferStrokeCount; + } + + public static class SpellRuntime + { + public static BaseRecognitionResult RecognizeBase(IReadOnlyList> strokes) + { + var candidates = Enum.GetValues(typeof(SpellFamily)) + .Cast() + .Select(family => GestureRecognizer.Recognize(strokes, family)) + .OrderByDescending(result => result.status == RecognitionStatus.Recognized ? 1 : 0) + .ThenByDescending(result => result.confidence) + .ToList(); + var best = candidates.First(); + var allPoints = strokes.SelectMany(stroke => stroke).Select(sample => sample.position).ToList(); + + return new BaseRecognitionResult + { + spell = best, + center = allPoints.Count == 0 ? Vector2.zero : new Vector2(allPoints.Average(point => point.x), allPoints.Average(point => point.y)), + worldScale = EstimateWorldScale(allPoints), + bufferStrokeCount = strokes.Count + }; + } + + public static CompiledSeal CreateSeal(BaseRecognitionResult baseResult, float now, float durationSeconds = 7.5f) + { + return new CompiledSeal + { + sealId = Guid.NewGuid().ToString("N")[..8], + baseFamily = baseResult.spell.recognizedFamily ?? baseResult.spell.targetFamily, + quality = baseResult.spell.quality, + worldCenter = baseResult.center, + worldScale = Mathf.Max(baseResult.worldScale, 0.65f), + createdAt = now, + expiresAt = now + durationSeconds + }; + } + + private static float EstimateWorldScale(IReadOnlyList points) + { + if (points.Count == 0) + { + return 1f; + } + + var min = new Vector2(points.Min(point => point.x), points.Min(point => point.y)); + var max = new Vector2(points.Max(point => point.x), points.Max(point => point.y)); + return Mathf.Max(Vector2.Distance(min, max), 0.5f); + } + } + + public static class OverlayRecognizer + { + private const int CloudPointCount = 48; + private static readonly IReadOnlyList Templates = BuildTemplates(); + + public static OverlayRecognitionResult Recognize( + IReadOnlyList> rawStrokes, + CompiledSeal seal) + { + var strokes = rawStrokes + .Select(stroke => stroke.Where(sample => IsFinite(sample.position)).ToList()) + .Where(stroke => stroke.Count >= 2) + .ToList(); + + if (strokes.Count == 0) + { + return new OverlayRecognitionResult + { + status = RecognitionStatus.Invalid, + feedbackReason = "overlay stroke가 충분히 남지 않았습니다." + }; + } + + var features = OverlayFeatures.From(strokes, seal); + var normalized = Normalize(strokes.Select(stroke => stroke.Select(sample => sample.position).ToList()).ToList()); + var scored = Templates + .Select(template => ScoreTemplate(template, normalized.cloud, features, seal)) + .OrderByDescending(candidate => candidate.score) + .ToList(); + var top = scored[0]; + var second = scored.Count > 1 ? scored[1] : top; + var margin = top.score - second.score; + + if (top.op == OverlayOperator.MartialAxis && !seal.overlayStack.Contains(OverlayOperator.VoidCut)) + { + return new OverlayRecognitionResult + { + status = top.score >= 0.52f ? RecognitionStatus.Incomplete : RecognitionStatus.Invalid, + recognizedOperator = top.op, + score = top.score, + shapeConfidence = top.shapeConfidence, + scaleRatio = features.scaleRatio, + anchorZone = top.anchorZone, + feedbackReason = "축 장식은 먼저 절단(void_cut)이 붙은 seal에서만 고정됩니다. 먼저 대각선 절단을 붙인 뒤 축을 그리세요." + }; + } + + var strongShapeMatch = top.score >= 0.72f && top.shapeConfidence >= 0.74f; + if (top.score >= 0.68f && top.shapeConfidence >= 0.48f && (margin >= 0.02f || strongShapeMatch)) + { + return new OverlayRecognitionResult + { + status = RecognitionStatus.Recognized, + recognizedOperator = top.op, + score = top.score, + shapeConfidence = top.shapeConfidence, + scaleRatio = features.scaleRatio, + anchorZone = top.anchorZone, + feedbackReason = $"{SpellLabels.Korean(top.op)} 장식이 seal에 붙었습니다." + }; + } + + if (top.score >= 0.5f) + { + return new OverlayRecognitionResult + { + status = RecognitionStatus.Ambiguous, + score = top.score, + shapeConfidence = top.shapeConfidence, + scaleRatio = features.scaleRatio, + anchorZone = top.anchorZone, + feedbackReason = "장식 후보가 겹쳐 아직 seal에 붙이지 않았습니다. 모양을 더 단순하게 다시 그려 보세요." + }; + } + + return new OverlayRecognitionResult + { + status = RecognitionStatus.Invalid, + score = top.score, + shapeConfidence = top.shapeConfidence, + scaleRatio = features.scaleRatio, + anchorZone = top.anchorZone, + feedbackReason = "장식의 모양, 위치, 크기가 seal과 충분히 맞지 않았습니다." + }; + } + + public static List> CreateCanonicalSamples( + OverlayOperator op, + Vector2 center, + float scale = 1.2f, + float timeStep = 0.04f) + { + var template = Templates.First(item => item.op == op); + var output = new List>(); + var time = 0f; + + foreach (var stroke in template.strokes) + { + var mapped = new List(); + foreach (var point in stroke) + { + mapped.Add(new StrokeSample(center + point * scale, time)); + time += timeStep; + } + output.Add(mapped); + time += timeStep * 3f; + } + + return output; + } + + private static OverlayScore ScoreTemplate( + OverlayTemplate template, + IReadOnlyList cloud, + OverlayFeatures features, + CompiledSeal seal) + { + var templateDistance = PointCloudDistance(cloud, template.normalizedCloud); + var templateScore = Mathf.Clamp01(1f - templateDistance / 0.72f); + var anchorScore = AnchorScore(features.centroid, seal.worldCenter, seal.worldScale, template.preferredAnchor); + var scaleScore = RangeScore(features.scaleRatio, template.minScale, template.maxScale); + var openScore = 1f - features.closure; + var score = templateScore; + var shape = templateScore; + + switch (template.op) + { + case OverlayOperator.SteelBrace: + { + var corner = Closeness(features.corners, 3f, 1.8f); + score = templateScore * 0.36f + corner * 0.24f + anchorScore * 0.16f + openScore * 0.14f + scaleScore * 0.10f; + shape = templateScore * 0.46f + corner * 0.34f + openScore * 0.20f; + break; + } + case OverlayOperator.ElectricFork: + { + var corner = Closeness(features.corners, 4f, 2f); + score = templateScore * 0.36f + corner * 0.28f + anchorScore * 0.16f + openScore * 0.10f + scaleScore * 0.10f; + shape = templateScore * 0.40f + corner * 0.40f + openScore * 0.20f; + break; + } + case OverlayOperator.IceBar: + { + var horizontal = Mathf.Clamp01(1f - Mathf.Abs(features.angleRadians) / (Mathf.PI / 8f)); + score = features.straightness * 0.36f + horizontal * 0.26f + anchorScore * 0.16f + scaleScore * 0.14f + templateScore * 0.08f; + shape = features.straightness * 0.52f + horizontal * 0.36f + templateScore * 0.12f; + break; + } + case OverlayOperator.SoulDot: + { + var compact = Mathf.Clamp01(1f - features.scaleRatio / 0.18f); + score = features.circularity * 0.30f + features.closure * 0.28f + anchorScore * 0.18f + compact * 0.14f + templateScore * 0.10f; + shape = features.circularity * 0.44f + features.closure * 0.44f + templateScore * 0.12f; + break; + } + case OverlayOperator.VoidCut: + { + var diagonal = Mathf.Clamp01(1f - Mathf.Abs(Mathf.Abs(features.angleRadians) - Mathf.PI / 4f) / (Mathf.PI / 8f)); + score = features.straightness * 0.32f + diagonal * 0.28f + anchorScore * 0.16f + scaleScore * 0.14f + templateScore * 0.10f; + shape = features.straightness * 0.48f + diagonal * 0.40f + templateScore * 0.12f; + break; + } + case OverlayOperator.MartialAxis: + { + var corner = Closeness(features.corners, 4f, 2f); + var axis = Mathf.Max(features.horizontalAxisScore, features.verticalAxisScore); + score = templateScore * 0.42f + corner * 0.24f + axis * 0.16f + anchorScore * 0.08f + scaleScore * 0.10f; + shape = templateScore * 0.36f + corner * 0.34f + axis * 0.30f; + break; + } + } + + return new OverlayScore + { + op = template.op, + score = Mathf.Clamp01(score), + shapeConfidence = Mathf.Clamp01(shape), + anchorZone = template.preferredAnchor + }; + } + + private static IReadOnlyList BuildTemplates() + { + var templates = new List + { + new(OverlayOperator.SteelBrace, "right", 0.14f, 0.55f, new[] + { + Poly(new Vector2(0.42f, -0.72f), new Vector2(-0.4f, -0.72f), new Vector2(-0.4f, 0.72f), new Vector2(0.42f, 0.72f)) + }), + new(OverlayOperator.ElectricFork, "upper_right", 0.12f, 0.48f, new[] + { + Poly(new Vector2(-0.52f, 0.58f), new Vector2(-0.08f, 0.02f), new Vector2(-0.44f, 0.02f), new Vector2(0.06f, -0.66f), new Vector2(0.5f, 0.02f)) + }), + new(OverlayOperator.IceBar, "core", 0.22f, 0.62f, new[] + { + Line(new Vector2(-0.78f, 0f), new Vector2(0.78f, 0f), 16) + }), + new(OverlayOperator.SoulDot, "core", 0.03f, 0.20f, new[] + { + Ellipse(0.20f, 0.20f, 24) + }), + new(OverlayOperator.VoidCut, "core", 0.12f, 0.54f, new[] + { + Line(new Vector2(-0.68f, 0.68f), new Vector2(0.68f, -0.68f), 18) + }), + new(OverlayOperator.MartialAxis, "core", 0.12f, 0.50f, new[] + { + Poly(new Vector2(0f, -0.74f), new Vector2(0f, 0.74f), new Vector2(0f, 0f), new Vector2(0.5f, 0f), new Vector2(-0.5f, 0f)) + }) + }; + + foreach (var template in templates) + { + template.normalizedCloud = Normalize(template.strokes).cloud; + } + + return templates; + } + + private static float AnchorScore(Vector2 point, Vector2 center, float scale, string zone) + { + var offset = Mathf.Max(scale * 0.28f, 0.25f); + var target = zone switch + { + "upper_right" => center + new Vector2(offset, offset), + "right" => center + new Vector2(offset, 0f), + "lower_right" => center + new Vector2(offset, -offset), + "upper" => center + new Vector2(0f, offset), + "left" => center + new Vector2(-offset, 0f), + _ => center + }; + return Mathf.Clamp01(1f - Vector2.Distance(point, target) / Mathf.Max(scale * 0.55f, 0.35f)); + } + + private static NormalizedGesture Normalize(IReadOnlyList> strokes) + { + var points = strokes.SelectMany(stroke => stroke).ToList(); + if (points.Count == 0) + { + return new NormalizedGesture(new List()); + } + + var min = new Vector2(points.Min(point => point.x), points.Min(point => point.y)); + var max = new Vector2(points.Max(point => point.x), points.Max(point => point.y)); + var center = (min + max) * 0.5f; + var scale = Mathf.Max(max.x - min.x, max.y - min.y, 0.001f); + var normalized = strokes.Select(stroke => stroke.Select(point => (point - center) / scale).ToList()).ToList(); + return new NormalizedGesture(Resample(normalized.SelectMany(stroke => stroke).ToList(), CloudPointCount)); + } + + private static List Resample(IReadOnlyList points, int count) + { + if (points.Count == 0) + { + return new List(); + } + + if (points.Count == 1) + { + return Enumerable.Repeat(points[0], count).ToList(); + } + + var total = PathLength(points); + var interval = total / Mathf.Max(count - 1f, 1f); + var output = new List { points[0] }; + var distanceSinceLast = 0f; + var previous = points[0]; + + for (var index = 1; index < points.Count; index++) + { + var current = points[index]; + var segment = Vector2.Distance(previous, current); + if (segment <= 0.0001f) + { + continue; + } + + while (distanceSinceLast + segment >= interval && output.Count < count) + { + var t = (interval - distanceSinceLast) / segment; + var inserted = Vector2.Lerp(previous, current, t); + output.Add(inserted); + segment -= interval - distanceSinceLast; + previous = inserted; + distanceSinceLast = 0f; + } + + distanceSinceLast += segment; + previous = current; + } + + while (output.Count < count) + { + output.Add(points[^1]); + } + + return output; + } + + private static float PointCloudDistance(IReadOnlyList left, IReadOnlyList right) + { + if (left.Count == 0 || right.Count == 0) + { + return 1f; + } + + var count = Mathf.Min(left.Count, right.Count); + var forward = 0f; + var reverse = 0f; + for (var index = 0; index < count; index++) + { + forward += Vector2.Distance(left[index], right[index]); + reverse += Vector2.Distance(left[index], right[count - 1 - index]); + } + + return Mathf.Min(forward, reverse) / count; + } + + private static List Line(Vector2 start, Vector2 end, int count) + { + return Enumerable.Range(0, count) + .Select(index => Vector2.Lerp(start, end, index / Mathf.Max(count - 1f, 1f))) + .ToList(); + } + + private static List Poly(params Vector2[] anchors) + { + var points = new List(); + for (var index = 1; index < anchors.Length; index++) + { + var line = Line(anchors[index - 1], anchors[index], 12); + if (points.Count > 0) + { + line.RemoveAt(0); + } + + points.AddRange(line); + } + + return points; + } + + private static List Ellipse(float radiusX, float radiusY, int count) + { + return Enumerable.Range(0, count + 1) + .Select(index => + { + var angle = index / (float)count * Mathf.PI * 2f; + return new Vector2(Mathf.Cos(angle) * radiusX, Mathf.Sin(angle) * radiusY); + }) + .ToList(); + } + + private static float RangeScore(float value, float minimum, float maximum) + { + if (value >= minimum && value <= maximum) + { + return 1f; + } + + var distanceToRange = value < minimum ? minimum - value : value - maximum; + return Mathf.Clamp01(1f - distanceToRange / 0.2f); + } + + private static float Closeness(float value, float target, float tolerance) + { + return Mathf.Clamp01(1f - Mathf.Abs(value - target) / Mathf.Max(tolerance, 0.001f)); + } + + private static float PathLength(IReadOnlyList points) + { + var length = 0f; + for (var index = 1; index < points.Count; index++) + { + length += Vector2.Distance(points[index - 1], points[index]); + } + + return length; + } + + private static bool IsFinite(Vector2 value) + { + return float.IsFinite(value.x) && float.IsFinite(value.y); + } + + private sealed class OverlayTemplate + { + public readonly OverlayOperator op; + public readonly string preferredAnchor; + public readonly float minScale; + public readonly float maxScale; + public readonly List> strokes; + public List normalizedCloud = new(); + + public OverlayTemplate(OverlayOperator op, string preferredAnchor, float minScale, float maxScale, IEnumerable> strokes) + { + this.op = op; + this.preferredAnchor = preferredAnchor; + this.minScale = minScale; + this.maxScale = maxScale; + this.strokes = strokes.ToList(); + } + } + + private sealed class OverlayScore + { + public OverlayOperator op; + public float score; + public float shapeConfidence; + public string anchorZone = ""; + } + + private readonly struct NormalizedGesture + { + public readonly List cloud; + + public NormalizedGesture(List cloud) + { + this.cloud = cloud; + } + } + + private readonly struct OverlayFeatures + { + public readonly Vector2 centroid; + public readonly float straightness; + public readonly int corners; + public readonly float closure; + public readonly float circularity; + public readonly float angleRadians; + public readonly float scaleRatio; + public readonly float horizontalAxisScore; + public readonly float verticalAxisScore; + + private OverlayFeatures( + Vector2 centroid, + float straightness, + int corners, + float closure, + float circularity, + float angleRadians, + float scaleRatio, + float horizontalAxisScore, + float verticalAxisScore) + { + this.centroid = centroid; + this.straightness = straightness; + this.corners = corners; + this.closure = closure; + this.circularity = circularity; + this.angleRadians = angleRadians; + this.scaleRatio = scaleRatio; + this.horizontalAxisScore = horizontalAxisScore; + this.verticalAxisScore = verticalAxisScore; + } + + public static OverlayFeatures From(List> strokes, CompiledSeal seal) + { + var all = strokes.SelectMany(stroke => stroke).Select(sample => sample.position).ToList(); + var centroid = new Vector2(all.Average(point => point.x), all.Average(point => point.y)); + var min = new Vector2(all.Min(point => point.x), all.Min(point => point.y)); + var max = new Vector2(all.Max(point => point.x), all.Max(point => point.y)); + var diagonal = Mathf.Max(Vector2.Distance(min, max), 0.001f); + var first = all[0]; + var last = all[^1]; + var closure = Mathf.Clamp01(1f - Vector2.Distance(first, last) / Mathf.Max(diagonal * 0.35f, 0.001f)); + var radii = all.Select(point => Vector2.Distance(point, centroid)).ToList(); + var meanRadius = radii.Average(); + var variance = radii.Average(radius => Mathf.Pow(radius - meanRadius, 2f)); + var circularity = Mathf.Clamp01(1f - Mathf.Sqrt(variance) / Mathf.Max(meanRadius * 0.45f, 0.001f)); + var straightness = Mathf.Clamp01(Vector2.Distance(first, last) / Mathf.Max(PathLength(all), 0.001f)); + var angle = NormalizeHalfPi(Mathf.Atan2(last.y - first.y, last.x - first.x)); + var horizontal = Mathf.Clamp01(1f - Mathf.Abs(centroid.y - seal.worldCenter.y) / Mathf.Max(seal.worldScale * 0.18f, 0.08f)); + var vertical = Mathf.Clamp01(1f - Mathf.Abs(centroid.x - seal.worldCenter.x) / Mathf.Max(seal.worldScale * 0.18f, 0.08f)); + + return new OverlayFeatures( + centroid, + straightness, + CountCorners(strokes), + closure, + circularity, + angle, + diagonal / Mathf.Max(seal.worldScale, 0.1f), + horizontal, + vertical); + } + + private static int CountCorners(List> strokes) + { + var points = strokes.SelectMany(stroke => stroke).Select(sample => sample.position).ToList(); + if (points.Count < 3) + { + return 0; + } + + var corners = 0; + for (var index = 1; index < points.Count - 1; index++) + { + var a = (points[index] - points[index - 1]).normalized; + var b = (points[index + 1] - points[index]).normalized; + if (Vector2.Angle(a, b) > 36f) + { + corners++; + } + } + + return Mathf.Clamp(corners, 0, 8); + } + + private static float NormalizeHalfPi(float angle) + { + while (angle > Mathf.PI / 2f) + { + angle -= Mathf.PI; + } + + while (angle < -Mathf.PI / 2f) + { + angle += Mathf.PI; + } + + return angle; + } + } + } + + public static class SpellLabels + { + public static string Korean(SpellFamily family) + { + return family switch + { + SpellFamily.Wind => "바람", + SpellFamily.Earth => "땅", + SpellFamily.Fire => "불꽃", + SpellFamily.Water => "물", + SpellFamily.Life => "생명", + _ => family.ToString() + }; + } + + public static string English(SpellFamily family) + { + return family.ToString().ToLowerInvariant(); + } + + public static string Korean(OverlayOperator op) + { + return op switch + { + OverlayOperator.SteelBrace => "보강", + OverlayOperator.ElectricFork => "번개", + OverlayOperator.IceBar => "얼음", + OverlayOperator.SoulDot => "집중", + OverlayOperator.VoidCut => "절단", + OverlayOperator.MartialAxis => "축", + _ => op.ToString() + }; + } + + public static string English(OverlayOperator op) + { + return op switch + { + OverlayOperator.SteelBrace => "steel_brace", + OverlayOperator.ElectricFork => "electric_fork", + OverlayOperator.IceBar => "ice_bar", + OverlayOperator.SoulDot => "soul_dot", + OverlayOperator.VoidCut => "void_cut", + OverlayOperator.MartialAxis => "martial_axis", + _ => op.ToString().ToLowerInvariant() + }; + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs.meta new file mode 100644 index 0000000..0f5798d --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Core/SpellRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b6f4ef7e58f4585a40466060d129c65 diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime.meta new file mode 100644 index 0000000..013536e --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: db0248104a905e243a24438399b4dfe4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs new file mode 100644 index 0000000..ec0f9c3 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs @@ -0,0 +1,1257 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace MagicExamHall +{ + public sealed class ExamGameController : MonoBehaviour + { + [Header("Scene References")] + public Camera mainCamera = null!; + public Transform player = null!; + public Canvas canvas = null!; + + private readonly List seals = new(); + private readonly List pulses = new(); + private readonly List floorObjects = new(); + private readonly List activeGoals = new(); + private readonly List activeHazards = new(); + private readonly Dictionary baseFailureCounts = new(); + + private ExamLogger logger = null!; + private WorldDrawingController worldDrawing = null!; + private FloorController floorController = null!; + private MagicNote magicNote = null!; + private EndingReport endingReport = null!; + private RectTransform hudPanel = null!; + private RectTransform notePanel = null!; + private RectTransform reportPanel = null!; + private Text hudTitle = null!; + private Text hudCopy = null!; + private Text floorProgress = null!; + private Text noteText = null!; + private Text reportText = null!; + private Font uiFont = null!; + private string sessionId = ""; + private int trialCounter; + private float floorStartedAt; + private float pendingAdvanceAt = -1f; + private Vector2 velocity; + private Vector2 safePosition; + + public int CurrentFloorNumber => floorController?.CurrentFloorNumber ?? 1; + public int FloorCount => floorController?.FloorCount ?? 5; + public int ActiveSealCount => seals.Count; + public int ActiveGoalCount => activeGoals.Count; + public Vector2 PlayerPosition => player == null ? Vector2.zero : player.position; + public bool HasEndingReport => reportPanel != null && reportPanel.gameObject.activeSelf; + public bool IsDrawingPanelVisible => false; + public bool IsResultPanelVisible => false; + public int CurrentAssistLevel { get; private set; } + public string LastHintText { get; private set; } = ""; + public string LastMagicNoteText => magicNote?.Text ?? ""; + public string OutputDirectory => logger?.OutputDirectory ?? ""; + public IReadOnlyList LastOverlayStack => seals.Count == 0 ? Array.Empty() : seals[^1].seal.overlayStack; + + private void Awake() + { + sessionId = $"unity-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString("N")[..6]}"; + logger = new ExamLogger(sessionId); + uiFont = Font.CreateDynamicFontFromOSFont(new[] { "Malgun Gothic", "Arial" }, 18); + floorController = new FloorController(); + magicNote = new MagicNote(); + endingReport = new EndingReport(); + ResolveSceneReferences(); + BuildUi(); + ConfigureWorldDrawing(); + LoadFloor(0); + } + + private void Update() + { + TickPlayer(); + TickSeals(); + TickPulses(); + TickHazards(); + TickFloorAdvance(); + magicNote.Tick(Time.deltaTime); + UpdateHud(); + } + + public BaseRecognitionResult CastSyntheticBaseForTests(SpellFamily family, Vector2 worldCenter) + { + var strokes = Offset(GestureRecognizer.CreateCanonicalSamples(family, 1.6f, 0.03f), worldCenter, 0.8f); + return ProcessSpellGroup(strokes, worldCenter, strokes.Count).baseResult; + } + + public BaseRecognitionResult CastRawBaseForTests(List> strokes, Vector2 worldCenter) + { + return ProcessBase(strokes, worldCenter, strokes.Count).baseResult; + } + + public OverlayRecognitionResult CastSyntheticOverlayForTests(OverlayOperator op, Vector2 worldCenter) + { + var nearestSeal = FindAttachableSeal(worldCenter); + var scale = nearestSeal == null ? 0.48f : nearestSeal.seal.worldScale * 0.24f; + var strokes = OverlayRecognizer.CreateCanonicalSamples(op, worldCenter, scale, 0.03f); + return ProcessSpellGroup(strokes, worldCenter, strokes.Count).overlayResult; + } + + public void CompleteCurrentFloorForTests() + { + foreach (var goal in activeGoals) + { + ActivateGoal(goal, "test_completion"); + } + EvaluateFloorCompletion(); + } + + public void AdvanceFloorForTests() + { + if (floorController.CurrentFloorIndex < floorController.FloorCount - 1) + { + LoadFloor(floorController.CurrentFloorIndex + 1); + } + else + { + ShowEndingReport(); + } + } + + public void LoadFloorForTests(int index) + { + LoadFloor(index); + } + + public void MovePlayerForTests(Vector2 worldPosition) + { + player.position = worldPosition; + } + + private void ResolveSceneReferences() + { + mainCamera ??= Camera.main; + if (mainCamera == null) + { + var cameraObject = new GameObject("Main Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0f, 0f, -10f); + mainCamera = cameraObject.AddComponent(); + mainCamera.orthographic = true; + mainCamera.orthographicSize = 6.2f; + mainCamera.backgroundColor = new Color(0.06f, 0.08f, 0.11f); + } + + if (player == null) + { + var playerObject = new GameObject("Apprentice"); + playerObject.transform.position = new Vector3(0f, -4.05f, 0f); + playerObject.transform.localScale = Vector3.one * 0.78f; + playerObject.AddComponent(); + var sprite = playerObject.AddComponent(); + sprite.kind = PixelSpriteKind.Player; + sprite.primary = new Color(0.95f, 0.92f, 0.78f); + sprite.secondary = new Color(0.28f, 0.62f, 0.96f); + sprite.sortingOrder = 30; + player = playerObject.transform; + } + + if (canvas == null) + { + var canvasObject = new GameObject("Exam Canvas"); + canvasObject.AddComponent(); + canvas = canvasObject.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + var scaler = canvasObject.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1280, 720); + canvasObject.AddComponent(); + } + + if (FindFirstObjectByType() == null) + { + var eventSystem = new GameObject("EventSystem"); + eventSystem.AddComponent(); + eventSystem.AddComponent(); + } + } + + private void ConfigureWorldDrawing() + { + worldDrawing = gameObject.GetComponent() ?? gameObject.AddComponent(); + worldDrawing.mainCamera = mainCamera; + worldDrawing.SpellBuffered += OnSpellBuffered; + } + + private void BuildUi() + { + ClearChildren(canvas.transform); + hudPanel = CreatePanel("HUD", canvas.transform, new Vector2(20, -20), new Vector2(560, 132), Anchor.TopLeft, new Color(0.04f, 0.055f, 0.075f, 0.88f)); + hudTitle = CreateText("HUD Title", hudPanel, "Magic Exam Hall", 24, FontStyle.Bold, new Vector2(16, -12), new Vector2(520, 28), Anchor.TopLeft); + hudCopy = CreateText("HUD Copy", hudPanel, "", 15, FontStyle.Normal, new Vector2(16, -46), new Vector2(520, 60), Anchor.TopLeft); + floorProgress = CreateText("Floor Progress", hudPanel, "", 15, FontStyle.Bold, new Vector2(16, 12), new Vector2(520, 24), Anchor.BottomLeft); + + notePanel = CreatePanel("Magic Note", canvas.transform, new Vector2(20, 20), new Vector2(560, 112), Anchor.BottomLeft, new Color(0.04f, 0.055f, 0.075f, 0.84f)); + noteText = CreateText("Note Text", notePanel, "", 14, FontStyle.Normal, new Vector2(14, -12), new Vector2(530, 88), Anchor.TopLeft); + + reportPanel = CreatePanel("Ending Report", canvas.transform, Vector2.zero, new Vector2(760, 520), Anchor.Center, new Color(0.035f, 0.045f, 0.065f, 0.96f)); + reportText = CreateText("Report Text", reportPanel, "", 17, FontStyle.Normal, new Vector2(28, -28), new Vector2(704, 464), Anchor.TopLeft); + reportPanel.gameObject.SetActive(false); + } + + private void LoadFloor(int index) + { + pendingAdvanceAt = -1f; + ClearFloorObjects(); + floorController.Load(index); + safePosition = new Vector2(0f, -4.05f); + player.position = safePosition; + floorStartedAt = Time.time; + activeGoals.Clear(); + activeGoals.AddRange(floorController.Current.goals.Select(goal => goal.Clone())); + activeHazards.Clear(); + activeHazards.AddRange(floorController.Current.hazards.Select(hazard => hazard.Clone())); + BuildFloorArt(floorController.Current); + magicNote.Show(floorController.Current.entryNote); + } + + private void BuildFloorArt(FloorDefinition floor) + { + var floorRoot = new GameObject($"Floor {floor.number} - {floor.title}"); + floorObjects.Add(floorRoot); + CreateWorldSprite("Stone Tile Floor", Vector2.zero, Vector3.one, new Color(0.15f, 0.17f, 0.22f), new Color(0.09f, 0.11f, 0.15f), PixelSpriteKind.FloorTile, -7, true, new Vector2(16.4f, 10f), floorRoot.transform); + CreateWorldSprite("North Carved Wall", new Vector2(0f, 4.95f), Vector3.one, new Color(0.22f, 0.20f, 0.27f), floor.accentColor, PixelSpriteKind.WallTrim, -4, true, new Vector2(16.4f, 1.15f), floorRoot.transform); + CreateWorldSprite("South Carved Wall", new Vector2(0f, -4.95f), Vector3.one, new Color(0.18f, 0.17f, 0.22f), new Color(0.50f, 0.40f, 0.20f), PixelSpriteKind.WallTrim, -4, true, new Vector2(16.4f, 0.8f), floorRoot.transform); + CreateWorldSprite("Center Runner", new Vector2(0f, 0.12f), Vector3.one, floor.rugColor, floor.accentColor, PixelSpriteKind.Rug, -5, true, new Vector2(2.2f, 7.6f), floorRoot.transform); + CreateWorldSprite("West Bookcase", new Vector2(-7.25f, 1.1f), Vector3.one * 1.15f, new Color(0.42f, 0.23f, 0.12f), floor.accentColor, PixelSpriteKind.Bookshelf, -1, false, Vector2.one, floorRoot.transform); + CreateWorldSprite("East Bookcase", new Vector2(7.25f, 1.1f), Vector3.one * 1.15f, new Color(0.42f, 0.23f, 0.12f), floor.accentColor, PixelSpriteKind.Bookshelf, -1, false, Vector2.one, floorRoot.transform); + CreateWorldSprite("Northwest Candle", new Vector2(-6.85f, 3.65f), Vector3.one * 0.85f, new Color(0.63f, 0.57f, 0.44f), new Color(1f, 0.56f, 0.15f), PixelSpriteKind.Candle, 2, false, Vector2.one, floorRoot.transform); + CreateWorldSprite("Northeast Candle", new Vector2(6.85f, 3.65f), Vector3.one * 0.85f, new Color(0.63f, 0.57f, 0.44f), new Color(1f, 0.56f, 0.15f), PixelSpriteKind.Candle, 2, false, Vector2.one, floorRoot.transform); + + foreach (var goal in activeGoals) + { + var body = CreateWorldSprite(goal.title, goal.position, Vector3.one * goal.visualScale, goal.color, Color.white, goal.kind, 3, false, Vector2.one, floorRoot.transform); + goal.body = body; + goal.renderer = body.GetComponent(); + if (goal.kind == PixelSpriteKind.RuneCircle) + { + body.transform.localScale *= 1.45f; + } + } + + foreach (var hazard in activeHazards) + { + var body = CreateWorldSprite(hazard.title, hazard.position, Vector3.one * hazard.radius, hazard.color, new Color(1f, 1f, 1f, 0.6f), PixelSpriteKind.Pulse, 1, false, Vector2.one, floorRoot.transform); + hazard.body = body; + } + } + + private void OnSpellBuffered(List> strokes, Vector2 center, int strokeCount) + { + ProcessSpellGroup(strokes, center, strokeCount); + } + + private ProcessedSpell ProcessSpellGroup(List> strokes, Vector2 center, int strokeCount) + { + trialCounter++; + var nearestSeal = FindAttachableSeal(center); + if (nearestSeal != null) + { + return ProcessOverlay(strokes, center, strokeCount, nearestSeal); + } + + return ProcessBase(strokes, center, strokeCount); + } + + private ProcessedSpell ProcessBase(List> strokes, Vector2 center, int strokeCount) + { + var baseResult = SpellRuntime.RecognizeBase(strokes); + baseResult.center = center; + baseResult.bufferStrokeCount = strokeCount; + var feedbackFamily = baseResult.spell.recognizedFamily ?? baseResult.spell.targetFamily; + var priorFailures = GetBaseFailureCount(feedbackFamily); + if (baseResult.spell.status != RecognitionStatus.Recognized || !baseResult.spell.recognizedFamily.HasValue) + { + var hintState = HintAssistance.ForAttempt(feedbackFamily, priorFailures, false, baseResult.spell); + baseFailureCounts[feedbackFamily] = priorFailures + 1; + CurrentAssistLevel = hintState.AssistLevelNumber; + LastHintText = hintState.body; + magicNote.Show(BuildBaseFailureNote(baseResult.spell, hintState)); + pulses.Add(new ParticlePulse(center, new Color(0.75f, 0.75f, 0.82f), weak: true)); + LogBaseAttempt(baseResult, null, "failed", hintState); + return new ProcessedSpell { baseResult = baseResult }; + } + + var seal = SpellRuntime.CreateSeal(baseResult, Time.time); + var successHintState = HintAssistance.ForAttempt(seal.baseFamily, priorFailures, true, baseResult.spell); + baseFailureCounts[seal.baseFamily] = 0; + CurrentAssistLevel = successHintState.AssistLevelNumber; + LastHintText = successHintState.assisted ? successHintState.body : ""; + var view = CreateSealView(seal); + seals.Add(view); + endingReport.RecordBase(seal.baseFamily, seal.quality, success: true); + var effect = ApplyBaseToGoals(seal.baseFamily, center); + magicNote.Show(BuildBaseSuccessNote(seal, effect, successHintState)); + pulses.Add(new ParticlePulse(center, FamilyColor(seal.baseFamily))); + LogBaseAttempt(baseResult, seal, effect.worldEffect, successHintState); + EvaluateFloorCompletion(); + return new ProcessedSpell { baseResult = baseResult }; + } + + private ProcessedSpell ProcessOverlay(List> strokes, Vector2 center, int strokeCount, SealView sealView) + { + var result = OverlayRecognizer.Recognize(strokes, sealView.seal); + if (!result.success) + { + CurrentAssistLevel = 1; + LastHintText = OverlayActionHint(result, sealView.seal); + magicNote.Show(BuildOverlayFailureNote(result, sealView.seal)); + pulses.Add(new ParticlePulse(center, new Color(0.75f, 0.75f, 0.82f), weak: true)); + LogOverlayAttempt(result, sealView.seal, center, strokeCount, "failed"); + return new ProcessedSpell { overlayResult = result }; + } + + var op = result.recognizedOperator!.Value; + if (sealView.seal.overlayStack.Contains(op)) + { + CurrentAssistLevel = 1; + LastHintText = "같은 장식 대신 아직 비어 있는 다른 장식을 seal 위에 그려 보세요."; + magicNote.Show($"{SpellLabels.Korean(op)} 장식은 이미 이 seal에 붙어 있습니다."); + } + else if (sealView.seal.overlayStack.Count >= 3) + { + CurrentAssistLevel = 1; + LastHintText = "새 base seal을 만든 뒤 남은 장식을 붙여 보세요."; + magicNote.Show("하나의 seal에는 overlay를 3개까지만 안정적으로 붙일 수 있습니다."); + } + else + { + sealView.seal.overlayStack.Add(op); + sealView.RefreshLabel(uiFont); + sealView.AddOverlayMark(op); + endingReport.RecordOverlay(op); + var effect = ApplyOverlayToGoals(sealView.seal, op, center); + CurrentAssistLevel = 0; + LastHintText = ""; + magicNote.Show(BuildOverlaySuccessNote(sealView.seal, op, effect)); + LogOverlayAttempt(result, sealView.seal, center, strokeCount, effect.worldEffect); + EvaluateFloorCompletion(); + } + + pulses.Add(new ParticlePulse(center, OverlayColor(op))); + return new ProcessedSpell { overlayResult = result }; + } + + private SealView FindAttachableSeal(Vector2 center) + { + return seals + .Where(seal => Time.time <= seal.seal.expiresAt) + .OrderBy(seal => Vector2.Distance(center, seal.seal.worldCenter)) + .FirstOrDefault(seal => Vector2.Distance(center, seal.seal.worldCenter) <= Mathf.Max(1.35f, seal.seal.worldScale * 0.95f)); + } + + private GoalEffect ApplyBaseToGoals(SpellFamily family, Vector2 center) + { + foreach (var goal in activeGoals.Where(goal => !goal.completed)) + { + if (goal.MatchesBase(family, center)) + { + ActivateGoal(goal, SpellLabels.English(family)); + return new GoalEffect($"{goal.discoveryNote}", goal.id); + } + } + + return new GoalEffect($"{SpellLabels.Korean(family)} seal이 바닥에 잠깐 고정되었습니다.", "seal_only"); + } + + private GoalEffect ApplyOverlayToGoals(CompiledSeal seal, OverlayOperator op, Vector2 center) + { + foreach (var goal in activeGoals.Where(goal => !goal.completed)) + { + if (goal.MatchesOverlay(seal, op, center)) + { + ActivateGoal(goal, SpellLabels.English(op)); + return new GoalEffect($"{goal.discoveryNote}", goal.id); + } + } + + return new GoalEffect($"{seal.Label}: overlay stack이 빛났습니다.", "overlay_stack"); + } + + private int GetBaseFailureCount(SpellFamily family) + { + return baseFailureCounts.TryGetValue(family, out var count) ? count : 0; + } + + private static string BuildBaseFailureNote(SpellResult result, HintState hintState) + { + return + $"노트: {SpellLabels.Korean(hintState.family)} 문양이 아직 안정되지 않았습니다.\n" + + $"{result.feedbackReason}\n" + + $"{hintState.title}: {hintState.body}"; + } + + private static string BuildBaseSuccessNote(CompiledSeal seal, GoalEffect effect, HintState hintState) + { + var assisted = hintState.assisted ? " 이전 힌트가 이번 시전에 도움이 되었습니다." : ""; + return $"{SpellLabels.Korean(seal.baseFamily)} seal 성공.{assisted}\n{effect.note}"; + } + + private static string BuildOverlayFailureNote(OverlayRecognitionResult result, CompiledSeal seal) + { + return + "노트: 장식이 seal에 안정적으로 붙지 않았습니다.\n" + + $"{result.feedbackReason}\n" + + $"다음: {OverlayActionHint(result, seal)}"; + } + + private static string BuildOverlaySuccessNote(CompiledSeal seal, OverlayOperator op, GoalEffect effect) + { + return $"{SpellLabels.Korean(op)} 장식 성공. {seal.Label}\n{effect.note}"; + } + + private static string OverlayActionHint(OverlayRecognitionResult result, CompiledSeal seal) + { + if (result.recognizedOperator == OverlayOperator.MartialAxis && !seal.overlayStack.Contains(OverlayOperator.VoidCut)) + { + return "먼저 같은 seal에 대각선 절단(void_cut)을 붙인 뒤, 중심을 가르는 축을 다시 그리세요."; + } + + if (result.scaleRatio > 0f && result.scaleRatio < 0.10f) + { + return "장식이 너무 작습니다. seal 중심을 기준으로 조금 더 크게 그려 보세요."; + } + + if (result.scaleRatio > 0.64f) + { + return "장식이 너무 큽니다. seal 안쪽에 들어오도록 작게 줄여 보세요."; + } + + return AnchorHint(result.anchorZone); + } + + private static string AnchorHint(string anchorZone) + { + return anchorZone switch + { + "upper_right" => "seal의 오른쪽 위 가장자리에서 짧고 또렷하게 다시 그려 보세요.", + "right" => "seal의 오른쪽 가장자리 옆에 붙이듯 다시 그려 보세요.", + "lower_right" => "seal의 오른쪽 아래 가장자리에서 다시 그려 보세요.", + "upper" => "seal의 위쪽 가장자리 가까이에서 다시 그려 보세요.", + "left" => "seal의 왼쪽 가장자리 가까이에서 다시 그려 보세요.", + _ => "seal 중심 가까이에 작게, 한 가지 장식만 다시 그려 보세요." + }; + } + + private void ActivateGoal(WorldStateGoal goal, string effect) + { + goal.completed = true; + if (goal.renderer != null) + { + goal.renderer.sprite = PixelArtFactory.CreateSprite($"{goal.title} Active", Color.white, goal.color, goal.kind); + goal.renderer.sharedMaterial = PixelMaterialProvider.SpriteMaterial; + } + if (goal.body != null) + { + goal.body.transform.localScale *= 1.15f; + } + endingReport.RecordDiscovery(goal.id, effect); + pulses.Add(new ParticlePulse(goal.position, goal.color)); + } + + private void EvaluateFloorCompletion() + { + if (!activeGoals.All(goal => goal.completed) || pendingAdvanceAt > 0f || HasEndingReport) + { + return; + } + + magicNote.Show(floorController.Current.completeNote); + pendingAdvanceAt = Time.time + 1.4f; + } + + private void TickFloorAdvance() + { + if (pendingAdvanceAt < 0f || Time.time < pendingAdvanceAt) + { + return; + } + + pendingAdvanceAt = -1f; + if (floorController.CurrentFloorIndex < floorController.FloorCount - 1) + { + LoadFloor(floorController.CurrentFloorIndex + 1); + return; + } + + ShowEndingReport(); + } + + private void TickPlayer() + { + if (HasEndingReport) + { + return; + } + + var input = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); + if (input.sqrMagnitude > 1f) + { + input.Normalize(); + } + + velocity = Vector2.Lerp(velocity, input * 4.2f, Time.deltaTime * 12f); + player.position += (Vector3)(velocity * Time.deltaTime); + player.position = new Vector3(Mathf.Clamp(player.position.x, -7.35f, 7.35f), Mathf.Clamp(player.position.y, -4.25f, 4.25f), 0f); + } + + private void TickHazards() + { + if (floorController.Current.number != 4) + { + return; + } + + foreach (var hazard in activeHazards) + { + hazard.Tick(Time.time); + if (Vector2.Distance(player.position, hazard.position) <= hazard.radius * 0.58f) + { + player.position = safePosition; + velocity = Vector2.zero; + magicNote.Show("균열이 몸을 밀어냈습니다. 가까운 안전 지점에서 다시 시작합니다."); + pulses.Add(new ParticlePulse(hazard.position, hazard.color, weak: true)); + return; + } + } + } + + private void TickSeals() + { + for (var index = seals.Count - 1; index >= 0; index--) + { + var seal = seals[index]; + var remaining = seal.seal.expiresAt - Time.time; + if (remaining <= 0f) + { + Destroy(seal.root); + seals.RemoveAt(index); + continue; + } + + seal.Tick(Time.time, remaining / Mathf.Max(seal.seal.expiresAt - seal.seal.createdAt, 0.1f)); + } + } + + private void TickPulses() + { + for (var index = pulses.Count - 1; index >= 0; index--) + { + var pulse = pulses[index]; + pulse.age += Time.deltaTime; + if (pulse.body == null) + { + pulse.body = CreateWorldSprite("Spell Pulse", pulse.position, Vector3.one * (pulse.weak ? 0.22f : 0.35f), pulse.color, Color.white, PixelSpriteKind.Pulse, 28); + } + + var t = pulse.age / (pulse.weak ? 0.7f : 0.95f); + pulse.body.transform.localScale = Vector3.one * Mathf.Lerp(pulse.weak ? 0.35f : 0.45f, pulse.weak ? 1.4f : 2.5f, t); + var renderer = pulse.body.GetComponent(); + renderer.sharedMaterial = PixelMaterialProvider.SpriteMaterial; + renderer.color = new Color(1f, 1f, 1f, Mathf.Lerp(0.8f, 0f, t)); + if (t >= 1f) + { + Destroy(pulse.body); + pulses.RemoveAt(index); + } + } + } + + private void UpdateHud() + { + if (HasEndingReport) + { + return; + } + + var floor = floorController.Current; + hudTitle.text = $"층 {floor.number}: {floor.title}"; + hudCopy.text = $"{floor.objective}\nWASD 이동 / 우클릭 hold로 바닥에 직접 문양을 그리세요."; + var completed = activeGoals.Count(goal => goal.completed); + floorProgress.text = $"탑 진행 {floorController.CurrentFloorNumber}/{floorController.FloorCount} 목표 {completed}/{activeGoals.Count} seal {seals.Count}"; + notePanel.gameObject.SetActive(magicNote.Visible); + noteText.text = magicNote.Text; + } + + private void ShowEndingReport() + { + reportPanel.gameObject.SetActive(true); + notePanel.gameObject.SetActive(false); + hudCopy.text = "입학 마법진이 다시 밝아졌습니다."; + logger.LogSurvey(new SurveyLog + { + sessionId = sessionId, + clarity = 5, + fairness = 5, + feedbackHelpfulness = 5, + controlFeeling = 5, + immersion = 5, + comment = "auto ending report", + completedTrials = endingReport.DiscoveryCount, + totalAttempts = trialCounter + }); + reportText.text = endingReport.BuildText(trialCounter, OutputDirectory); + } + + private SealView CreateSealView(CompiledSeal seal) + { + var root = CreateWorldSprite($"Seal {seal.sealId}", seal.worldCenter, Vector3.one * Mathf.Max(seal.worldScale * 1.08f, 0.9f), FamilyColor(seal.baseFamily), Color.white, PixelSpriteKind.RuneCircle, 18); + var labelObject = new GameObject("Rune Label"); + labelObject.transform.SetParent(root.transform, false); + labelObject.transform.localPosition = new Vector3(0f, 0.78f, 0f); + var canvasObject = new GameObject("Rune Label Canvas"); + canvasObject.transform.SetParent(labelObject.transform, false); + var worldCanvas = canvasObject.AddComponent(); + worldCanvas.renderMode = RenderMode.WorldSpace; + worldCanvas.sortingOrder = 40; + var rect = canvasObject.GetComponent() ?? canvasObject.AddComponent(); + rect.sizeDelta = new Vector2(2.7f, 0.45f); + canvasObject.transform.localScale = Vector3.one * 0.012f; + var textObject = new GameObject("Text"); + textObject.transform.SetParent(canvasObject.transform, false); + var textRect = textObject.AddComponent(); + textRect.anchorMin = Vector2.zero; + textRect.anchorMax = Vector2.one; + textRect.offsetMin = Vector2.zero; + textRect.offsetMax = Vector2.zero; + var text = textObject.AddComponent(); + text.font = uiFont; + text.fontSize = 26; + text.alignment = TextAnchor.MiddleCenter; + text.color = Color.white; + text.text = seal.Label; + return new SealView(root, seal, text); + } + + private void LogBaseAttempt(BaseRecognitionResult result, CompiledSeal seal, string worldEffect, HintState hintState = null) + { + var success = result.spell.status == RecognitionStatus.Recognized; + logger.LogAttempt(new AttemptLog + { + sessionId = sessionId, + trialId = trialCounter.ToString(CultureInfo.InvariantCulture), + targetFamily = "", + recognizedFamily = result.spell.RecognizedFamilyText, + phase = SpellPhase.Base.ToString(), + baseFamily = result.spell.RecognizedFamilyText, + overlayStack = "", + sealId = seal?.sealId ?? "", + floorId = floorController.Current.number.ToString(CultureInfo.InvariantCulture), + targetObject = worldEffect, + worldEffect = worldEffect, + status = result.spell.status.ToString(), + confidence = result.spell.confidence, + closure = result.spell.quality.closure, + smoothness = result.spell.quality.smoothness, + tempo = result.spell.quality.tempo, + stability = result.spell.quality.stability, + rotationBias = result.spell.quality.rotationBias, + worldX = result.center.x, + worldY = result.center.y, + bufferStrokeCount = result.bufferStrokeCount, + attemptIndex = trialCounter, + elapsedMs = Mathf.RoundToInt((Time.time - floorStartedAt) * 1000f), + feedbackViewed = true, + success = success, + hintShown = hintState?.hintShown ?? !success, + assistLevel = hintState?.AssistLevelNumber ?? (success ? 0 : 1), + assisted = hintState?.assisted ?? false + }); + } + + private void LogOverlayAttempt(OverlayRecognitionResult result, CompiledSeal seal, Vector2 center, int strokeCount, string worldEffect) + { + logger.LogAttempt(new AttemptLog + { + sessionId = sessionId, + trialId = trialCounter.ToString(CultureInfo.InvariantCulture), + targetFamily = "", + recognizedFamily = result.OperatorText, + phase = SpellPhase.Overlay.ToString(), + baseFamily = SpellLabels.English(seal.baseFamily), + overlayStack = string.Join(">", seal.overlayStack.Select(SpellLabels.English)), + sealId = seal.sealId, + floorId = floorController.Current.number.ToString(CultureInfo.InvariantCulture), + targetObject = worldEffect, + worldEffect = worldEffect, + status = result.status.ToString(), + confidence = result.score, + closure = 0f, + smoothness = result.shapeConfidence, + tempo = 0f, + stability = 0f, + rotationBias = result.scaleRatio, + worldX = center.x, + worldY = center.y, + bufferStrokeCount = strokeCount, + attemptIndex = trialCounter, + elapsedMs = Mathf.RoundToInt((Time.time - floorStartedAt) * 1000f), + feedbackViewed = true, + success = result.success, + hintShown = !result.success, + assistLevel = result.success ? 0 : CurrentAssistLevel, + assisted = false + }); + } + + private void ClearFloorObjects() + { + foreach (var body in floorObjects) + { + if (body != null) + { + Destroy(body); + } + } + floorObjects.Clear(); + foreach (var seal in seals) + { + if (seal.root != null) + { + Destroy(seal.root); + } + } + seals.Clear(); + } + + private GameObject CreateWorldSprite(string name, Vector2 position, Vector3 scale, Color primary, Color secondary, PixelSpriteKind kind, int sortingOrder, bool tiled = false, Vector2 tiledSize = default, Transform parent = null) + { + var body = new GameObject(name); + body.transform.SetParent(parent, true); + body.transform.position = position; + body.transform.localScale = scale; + body.AddComponent(); + var pixelSprite = body.AddComponent(); + pixelSprite.kind = kind; + pixelSprite.primary = primary; + pixelSprite.secondary = secondary; + pixelSprite.sortingOrder = sortingOrder; + pixelSprite.tiled = tiled; + pixelSprite.tiledSize = tiledSize == default ? Vector2.one : tiledSize; + pixelSprite.Apply(); + return body; + } + + private Image CreateImage(string name, Transform parent, Vector2 anchoredPosition, Vector2 size, Anchor anchor, Color color) + { + var body = new GameObject(name); + body.transform.SetParent(parent, false); + var rect = body.AddComponent(); + ApplyAnchor(rect, anchor); + rect.anchoredPosition = anchoredPosition; + rect.sizeDelta = size; + var image = body.AddComponent(); + image.color = color; + image.material = PixelMaterialProvider.UiMaterial; + return image; + } + + private RectTransform CreatePanel(string name, Transform parent, Vector2 anchoredPosition, Vector2 size, Anchor anchor, Color color) + { + return CreateImage(name, parent, anchoredPosition, size, anchor, color).rectTransform; + } + + private Text CreateText(string name, Transform parent, string content, int size, FontStyle style, Vector2 anchoredPosition, Vector2 rectSize, Anchor anchor) + { + var body = new GameObject(name); + body.transform.SetParent(parent, false); + var rect = body.AddComponent(); + ApplyAnchor(rect, anchor); + rect.anchoredPosition = anchoredPosition; + rect.sizeDelta = rectSize; + var text = body.AddComponent(); + text.font = uiFont; + text.text = content; + text.fontSize = size; + text.fontStyle = style; + text.color = Color.white; + text.horizontalOverflow = HorizontalWrapMode.Wrap; + text.verticalOverflow = VerticalWrapMode.Overflow; + return text; + } + + private static void ClearChildren(Transform parent) + { + for (var index = parent.childCount - 1; index >= 0; index--) + { + DestroyImmediate(parent.GetChild(index).gameObject); + } + } + + private static void ApplyAnchor(RectTransform rect, Anchor anchor) + { + switch (anchor) + { + case Anchor.TopLeft: + rect.anchorMin = rect.anchorMax = new Vector2(0f, 1f); + rect.pivot = new Vector2(0f, 1f); + break; + case Anchor.BottomLeft: + rect.anchorMin = rect.anchorMax = new Vector2(0f, 0f); + rect.pivot = new Vector2(0f, 0f); + break; + case Anchor.Center: + rect.anchorMin = rect.anchorMax = new Vector2(0.5f, 0.5f); + rect.pivot = new Vector2(0.5f, 0.5f); + break; + } + } + + private static Color FamilyColor(SpellFamily family) + { + return family switch + { + SpellFamily.Fire => new Color(1f, 0.31f, 0.18f), + SpellFamily.Water => new Color(0.24f, 0.48f, 0.86f), + SpellFamily.Wind => new Color(0.44f, 0.72f, 0.74f), + SpellFamily.Earth => new Color(0.74f, 0.55f, 0.32f), + SpellFamily.Life => new Color(0.35f, 0.86f, 0.42f), + _ => Color.white + }; + } + + private static Color OverlayColor(OverlayOperator op) + { + return op switch + { + OverlayOperator.SteelBrace => new Color(0.78f, 0.82f, 0.86f), + OverlayOperator.ElectricFork => new Color(1f, 0.9f, 0.22f), + OverlayOperator.IceBar => new Color(0.48f, 0.84f, 1f), + OverlayOperator.SoulDot => new Color(0.95f, 0.62f, 1f), + OverlayOperator.VoidCut => new Color(0.58f, 0.42f, 0.92f), + OverlayOperator.MartialAxis => new Color(1f, 0.58f, 0.34f), + _ => Color.white + }; + } + + private static List> Offset(List> strokes, Vector2 center, float canonicalCenter) + { + return strokes + .Select(stroke => stroke.Select(sample => new StrokeSample(sample.position - Vector2.one * canonicalCenter + center, sample.time)).ToList()) + .ToList(); + } + + private enum Anchor + { + Center, + TopLeft, + BottomLeft + } + + private readonly struct GoalEffect + { + public readonly string note; + public readonly string worldEffect; + + public GoalEffect(string note, string worldEffect) + { + this.note = note; + this.worldEffect = worldEffect; + } + } + + private sealed class ProcessedSpell + { + public BaseRecognitionResult baseResult = null; + public OverlayRecognitionResult overlayResult = null; + } + + private sealed class ParticlePulse + { + public readonly Vector2 position; + public readonly Color color; + public readonly bool weak; + public GameObject body; + public float age; + + public ParticlePulse(Vector2 position, Color color, bool weak = false) + { + this.position = position; + this.color = color; + this.weak = weak; + } + } + + private sealed class SealView + { + public readonly GameObject root; + public readonly CompiledSeal seal; + private readonly Text label; + private readonly List overlayMarks = new(); + + public SealView(GameObject root, CompiledSeal seal, Text label) + { + this.root = root; + this.seal = seal; + this.label = label; + } + + public void RefreshLabel(Font font) + { + label.font = font; + label.text = seal.Label; + } + + public void AddOverlayMark(OverlayOperator op) + { + var index = overlayMarks.Count; + var mark = new GameObject($"Overlay Mark {op}"); + mark.transform.SetParent(root.transform, false); + var angle = index * Mathf.PI * 2f / 3f + Mathf.PI / 4f; + mark.transform.localPosition = new Vector3(Mathf.Cos(angle) * 0.52f, Mathf.Sin(angle) * 0.52f, -0.1f); + mark.transform.localScale = Vector3.one * 0.22f; + var renderer = mark.AddComponent(); + renderer.sprite = PixelArtFactory.CreateSprite($"Overlay {op}", OverlayColor(op), Color.white, PixelSpriteKind.Pulse); + renderer.sharedMaterial = PixelMaterialProvider.SpriteMaterial; + renderer.sortingOrder = 24; + overlayMarks.Add(mark); + } + + public void Tick(float time, float normalizedLifetime) + { + if (root == null) + { + return; + } + root.transform.localScale = Vector3.one * Mathf.Lerp(0.72f, 1f, normalizedLifetime) * (1f + Mathf.Sin(time * 4f) * 0.025f); + var renderer = root.GetComponent(); + if (renderer != null) + { + renderer.color = new Color(1f, 1f, 1f, Mathf.Clamp01(normalizedLifetime + 0.16f)); + } + } + } + } + + public sealed class MagicNote + { + private float ttl; + public string Text { get; private set; } = ""; + public bool Visible => ttl > 0f && !string.IsNullOrWhiteSpace(Text); + + public void Show(string text) + { + Text = text; + ttl = 4.4f; + } + + public void Tick(float deltaTime) + { + ttl = Mathf.Max(0f, ttl - deltaTime); + } + } + + public sealed class EndingReport + { + private readonly Dictionary baseUse = new(); + private readonly Dictionary overlayUse = new(); + private readonly HashSet discoveries = new(); + private readonly List qualityScores = new(); + + public int DiscoveryCount => discoveries.Count; + + public void RecordBase(SpellFamily family, QualityVector quality, bool success) + { + baseUse[family] = baseUse.TryGetValue(family, out var count) ? count + 1 : 1; + if (success) + { + qualityScores.Add(quality.Average()); + } + } + + public void RecordOverlay(OverlayOperator op) + { + overlayUse[op] = overlayUse.TryGetValue(op, out var count) ? count + 1 : 1; + } + + public void RecordDiscovery(string id, string effect) + { + discoveries.Add($"{id}:{effect}"); + } + + public string BuildText(int totalAttempts, string outputDirectory) + { + var favoriteBase = baseUse.Count == 0 ? "없음" : SpellLabels.Korean(baseUse.OrderByDescending(item => item.Value).First().Key); + var favoriteOverlay = overlayUse.Count == 0 ? "없음" : SpellLabels.Korean(overlayUse.OrderByDescending(item => item.Value).First().Key); + var averageQuality = qualityScores.Count == 0 ? 0f : qualityScores.Average() * 100f; + return + "입학 마법진 복구 완료\n\n" + + $"총 시도: {totalAttempts}\n" + + $"가장 많이 사용한 base: {favoriteBase}\n" + + $"가장 많이 사용한 overlay: {favoriteOverlay}\n" + + $"발견한 반응: {discoveries.Count}\n" + + $"평균 품질 경향: {averageQuality:0}%\n\n" + + "마법 노트가 마지막 문장을 남깁니다.\n" + + "\"정답을 외운 것이 아니라, 탑이 알아들을 문법을 만들었다.\"\n\n" + + $"로그 저장 위치:\n{outputDirectory}"; + } + } + + public sealed class FloorController + { + private readonly List floors; + public int CurrentFloorIndex { get; private set; } + public int CurrentFloorNumber => CurrentFloorIndex + 1; + public int FloorCount => floors.Count; + public FloorDefinition Current => floors[CurrentFloorIndex]; + + public FloorController() + { + floors = FloorDefinition.BuildAll(); + } + + public void Load(int index) + { + CurrentFloorIndex = Mathf.Clamp(index, 0, floors.Count - 1); + } + } + + public sealed class FloorDefinition + { + public int number; + public string title = ""; + public string objective = ""; + public string entryNote = ""; + public string completeNote = ""; + public Color accentColor = Color.white; + public Color rugColor = new(0.52f, 0.12f, 0.18f); + public readonly List goals = new(); + public readonly List hazards = new(); + + public static List BuildAll() + { + return new List + { + new() + { + number = 1, + title = "발착층", + objective = "다섯 base 문양으로 시험장의 반응 오브젝트를 깨우세요.", + entryNote = "노트: 바닥에 직접 그린 선은 탑이 읽는 말이 된다.", + completeNote = "승강 룬이 깨어났습니다. 탑이 다음 층을 열어 줍니다.", + accentColor = new Color(0.96f, 0.68f, 0.28f), + rugColor = new Color(0.54f, 0.12f, 0.18f), + goals = + { + WorldStateGoal.Base("ember", "불씨", SpellFamily.Fire, new Vector2(-5.5f, 2.6f), new Color(1f, 0.31f, 0.18f), "불씨가 살아나며 오래된 룬을 데웁니다."), + WorldStateGoal.Base("puddle", "물웅덩이", SpellFamily.Water, new Vector2(0f, 3.0f), new Color(0.24f, 0.48f, 0.86f), "물길이 맑아지며 바닥 홈을 채웁니다."), + WorldStateGoal.Base("vane", "바람개비", SpellFamily.Wind, new Vector2(5.5f, 2.6f), new Color(0.44f, 0.72f, 0.74f), "바람개비가 돌며 승강 룬에 숨을 넣습니다."), + WorldStateGoal.Base("pillar", "돌기둥", SpellFamily.Earth, new Vector2(-3.2f, -2.45f), new Color(0.74f, 0.55f, 0.32f), "돌기둥이 제자리를 잡아 시험장을 고정합니다."), + WorldStateGoal.Base("vine", "마른 덩굴", SpellFamily.Life, new Vector2(3.2f, -2.45f), new Color(0.35f, 0.86f, 0.42f), "마른 덩굴에 초록 빛이 돌아옵니다.") + } + }, + new() + { + number = 2, + title = "반응층", + objective = "base seal 위에 6개 overlay를 모두 붙여 반응 벽화를 깨우세요.", + entryNote = "노트: base는 동사이고, 장식은 동사의 방식을 바꾼다.", + completeNote = "여섯 장식이 모두 벽화에 새겨졌습니다.", + accentColor = new Color(0.65f, 0.48f, 0.92f), + rugColor = new Color(0.18f, 0.18f, 0.42f), + goals = + { + WorldStateGoal.Overlay("steel", "보강 벽화", OverlayOperator.SteelBrace, new Vector2(-5.8f, 2.7f), new Color(0.78f, 0.82f, 0.86f), "열린 brace가 seal 가장자리를 단단히 붙잡습니다."), + WorldStateGoal.Overlay("fork", "번개 벽화", OverlayOperator.ElectricFork, new Vector2(-3.2f, 3.0f), new Color(1f, 0.9f, 0.22f), "번개는 갈라진 길을 좋아합니다."), + WorldStateGoal.Overlay("ice", "얼음 벽화", OverlayOperator.IceBar, new Vector2(-0.65f, 3.0f), new Color(0.48f, 0.84f, 1f), "수평 막대가 흐름을 잠깐 멈춥니다."), + WorldStateGoal.Overlay("soul", "집중 벽화", OverlayOperator.SoulDot, new Vector2(1.9f, 3.0f), new Color(0.95f, 0.62f, 1f), "작은 점 하나가 주문의 핵심을 잡습니다."), + WorldStateGoal.Overlay("void", "절단 벽화", OverlayOperator.VoidCut, new Vector2(4.45f, 3.0f), new Color(0.58f, 0.42f, 0.92f), "절단은 엉킨 흐름을 분리합니다."), + WorldStateGoal.Overlay("axis", "축 벽화", OverlayOperator.MartialAxis, new Vector2(6.4f, 2.7f), new Color(1f, 0.58f, 0.34f), "절단 뒤에 축이 섭니다.") + } + }, + new() + { + number = 3, + title = "흐름층", + objective = "base + overlay 조합으로 끊어진 공중 다리의 네 경로를 연결하세요.", + entryNote = "노트: 길은 하나가 아니다. 같은 목표도 여러 문법으로 이어진다.", + completeNote = "다리 조각들이 이어져 발밑에 경로가 생겼습니다.", + accentColor = new Color(0.48f, 0.8f, 0.92f), + rugColor = new Color(0.12f, 0.34f, 0.42f), + goals = + { + WorldStateGoal.Combo("brace_bridge", "보강 지지대", SpellFamily.Earth, OverlayOperator.SteelBrace, new Vector2(-4.6f, 1.8f), new Color(0.74f, 0.55f, 0.32f), "땅과 보강이 떠 있는 돌을 지지합니다."), + WorldStateGoal.Combo("axis_bridge", "축 정렬 발판", SpellFamily.Wind, OverlayOperator.MartialAxis, new Vector2(4.6f, 1.8f), new Color(0.44f, 0.72f, 0.74f), "바람과 축이 발판의 방향을 맞춥니다."), + WorldStateGoal.Combo("vine_bridge", "덩굴 고리", SpellFamily.Life, OverlayOperator.SoulDot, new Vector2(-3.2f, -2.3f), new Color(0.35f, 0.86f, 0.42f), "생명과 집중이 고리 모양 덩굴을 키웁니다."), + WorldStateGoal.Combo("ice_bridge", "얼음 다리", SpellFamily.Water, OverlayOperator.IceBar, new Vector2(3.2f, -2.3f), new Color(0.48f, 0.84f, 1f), "물과 얼음이 잠깐 딛고 설 길을 만듭니다.") + } + }, + new() + { + number = 4, + title = "균열층", + objective = "위험한 균열을 피해 룬 폭주를 안정화하세요.", + entryNote = "노트: 실패는 떨어지는 것이 아니라, 안전한 자리에서 다시 보는 일이다.", + completeNote = "균열의 박동이 잦아들고 통로가 안정됩니다.", + accentColor = new Color(1f, 0.42f, 0.28f), + rugColor = new Color(0.42f, 0.10f, 0.16f), + goals = + { + WorldStateGoal.Base("earth_stable", "흔들림 고정", SpellFamily.Earth, new Vector2(-5.2f, 2.4f), new Color(0.74f, 0.55f, 0.32f), "땅의 주문이 균열의 가장자리를 붙잡습니다."), + WorldStateGoal.Overlay("ice_still", "냉각 정지", OverlayOperator.IceBar, new Vector2(-1.7f, 2.9f), new Color(0.48f, 0.84f, 1f), "얼음 막대가 폭주의 열을 낮춥니다."), + WorldStateGoal.Overlay("void_split", "오염 분리", OverlayOperator.VoidCut, new Vector2(1.8f, 2.9f), new Color(0.58f, 0.42f, 0.92f), "절단이 위험한 흐름을 끊어 냅니다."), + WorldStateGoal.Overlay("fork_ground", "전도 분산", OverlayOperator.ElectricFork, new Vector2(5.2f, 2.4f), new Color(1f, 0.9f, 0.22f), "번개 갈래가 남은 전하를 흩습니다.") + }, + hazards = + { + new HazardZone("Crack West", new Vector2(-3.1f, -0.4f), 1.1f, new Color(1f, 0.18f, 0.15f)), + new HazardZone("Crack Center", new Vector2(0.3f, -0.1f), 1.25f, new Color(1f, 0.18f, 0.15f)), + new HazardZone("Crack East", new Vector2(3.7f, -0.55f), 1.05f, new Color(1f, 0.18f, 0.15f)) + } + }, + new() + { + number = 5, + title = "성좌심", + objective = "대형 입학 마법진의 여섯 요구치를 어떤 조합으로든 채우세요.", + entryNote = "노트: 마지막 시험은 정답을 묻지 않는다. 탑이 요구하는 상태를 채워라.", + completeNote = "대형 마법진이 복구되었습니다. 입학 시험이 끝났습니다.", + accentColor = new Color(0.95f, 0.75f, 0.34f), + rugColor = new Color(0.18f, 0.16f, 0.32f), + goals = + { + WorldStateGoal.Combo("stability", "안정", SpellFamily.Earth, OverlayOperator.SteelBrace, new Vector2(-4.8f, 2.6f), new Color(0.74f, 0.55f, 0.32f), "안정의 조각이 고정됩니다."), + WorldStateGoal.Base("cleanse", "정화", SpellFamily.Water, new Vector2(-1.6f, 3.0f), new Color(0.24f, 0.48f, 0.86f), "정화의 조각이 맑아집니다."), + WorldStateGoal.Combo("connection", "연결", SpellFamily.Life, OverlayOperator.SoulDot, new Vector2(1.6f, 3.0f), new Color(0.35f, 0.86f, 0.42f), "연결의 조각이 새싹처럼 이어집니다."), + WorldStateGoal.Overlay("cut", "절단", OverlayOperator.VoidCut, new Vector2(4.8f, 2.6f), new Color(0.58f, 0.42f, 0.92f), "절단의 조각이 오염을 분리합니다."), + WorldStateGoal.Overlay("focus", "집중", OverlayOperator.SoulDot, new Vector2(-2.2f, -2.5f), new Color(0.95f, 0.62f, 1f), "집중의 조각이 심장을 밝힙니다."), + WorldStateGoal.Base("flow", "흐름", SpellFamily.Wind, new Vector2(2.2f, -2.5f), new Color(0.44f, 0.72f, 0.74f), "흐름의 조각이 원을 다시 돌립니다.") + } + } + }; + } + } + + public sealed class WorldStateGoal + { + public string id; + public string title; + public Vector2 position; + public Color color; + public PixelSpriteKind kind; + public SpellFamily? requiredBase; + public OverlayOperator? requiredOverlay; + public SpellFamily? comboBase; + public OverlayOperator? comboOverlay; + public string discoveryNote; + public bool completed; + public float radius = 2.15f; + public float visualScale = 1f; + public GameObject body; + public SpriteRenderer renderer; + + private WorldStateGoal(string id, string title, Vector2 position, Color color, PixelSpriteKind kind, string discoveryNote) + { + this.id = id; + this.title = title; + this.position = position; + this.color = color; + this.kind = kind; + this.discoveryNote = discoveryNote; + } + + public static WorldStateGoal Base(string id, string title, SpellFamily family, Vector2 position, Color color, string note) + { + return new WorldStateGoal(id, title, position, color, PixelSpriteKind.Target, note) + { + requiredBase = family, + visualScale = 0.9f + }; + } + + public static WorldStateGoal Overlay(string id, string title, OverlayOperator op, Vector2 position, Color color, string note) + { + return new WorldStateGoal(id, title, position, color, PixelSpriteKind.RuneCircle, note) + { + requiredOverlay = op, + radius = 99f, + visualScale = 0.75f + }; + } + + public static WorldStateGoal Combo(string id, string title, SpellFamily family, OverlayOperator op, Vector2 position, Color color, string note) + { + return new WorldStateGoal(id, title, position, color, PixelSpriteKind.RuneCircle, note) + { + comboBase = family, + comboOverlay = op, + radius = 99f, + visualScale = 0.85f + }; + } + + public bool MatchesBase(SpellFamily family, Vector2 center) + { + return requiredBase == family && Vector2.Distance(center, position) <= radius; + } + + public bool MatchesOverlay(CompiledSeal seal, OverlayOperator op, Vector2 center) + { + if (requiredOverlay == op) + { + return true; + } + + return comboBase == seal.baseFamily && comboOverlay == op; + } + + public WorldStateGoal Clone() + { + return new WorldStateGoal(id, title, position, color, kind, discoveryNote) + { + requiredBase = requiredBase, + requiredOverlay = requiredOverlay, + comboBase = comboBase, + comboOverlay = comboOverlay, + radius = radius, + visualScale = visualScale + }; + } + } + + public sealed class HazardZone + { + public string title; + public Vector2 position; + public float radius; + public Color color; + public GameObject body; + + public HazardZone(string title, Vector2 position, float radius, Color color) + { + this.title = title; + this.position = position; + this.radius = radius; + this.color = color; + } + + public HazardZone Clone() + { + return new HazardZone(title, position, radius, color); + } + + public void Tick(float time) + { + if (body == null) + { + return; + } + + body.transform.localScale = Vector3.one * radius * (1f + Mathf.Sin(time * 4f) * 0.08f); + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs.meta new file mode 100644 index 0000000..41d367d --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1009b31849aa60743ab98b92f924506d \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelArtFactory.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelArtFactory.cs new file mode 100644 index 0000000..339a2fc --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelArtFactory.cs @@ -0,0 +1,407 @@ +using UnityEngine; + +namespace MagicExamHall +{ + public static class PixelArtFactory + { + private const int Size = 32; + private const float PixelsPerUnit = 16f; + + public static Sprite CreateSprite(string name, Color primary, Color secondary, PixelSpriteKind kind) + { + var texture = new Texture2D(Size, Size, TextureFormat.RGBA32, false) + { + name = $"{name} Texture", + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp + }; + + Clear(texture); + switch (kind) + { + case PixelSpriteKind.Player: + DrawPlayer(texture, primary, secondary); + break; + case PixelSpriteKind.Station: + DrawStation(texture, primary, secondary); + break; + case PixelSpriteKind.Target: + DrawTarget(texture, primary, secondary); + break; + case PixelSpriteKind.Pulse: + DrawPulse(texture, primary); + break; + case PixelSpriteKind.FloorTile: + DrawFloorTile(texture, primary, secondary); + break; + case PixelSpriteKind.WallTrim: + DrawWallTrim(texture, primary, secondary); + break; + case PixelSpriteKind.Rug: + DrawRug(texture, primary, secondary); + break; + case PixelSpriteKind.Bookshelf: + DrawBookshelf(texture, primary, secondary); + break; + case PixelSpriteKind.Candle: + DrawCandle(texture, primary, secondary); + break; + case PixelSpriteKind.RuneCircle: + DrawRuneCircle(texture, primary, secondary); + break; + } + + texture.Apply(); + return Sprite.Create(texture, new Rect(0, 0, Size, Size), new Vector2(0.5f, 0.5f), PixelsPerUnit); + } + + private static void DrawPlayer(Texture2D texture, Color skin, Color robe) + { + var outline = new Color(0.035f, 0.032f, 0.045f, 1f); + var robeDark = Shade(robe, 0.48f); + var robeMid = Shade(robe, 0.78f); + var robeLight = Mix(robe, Color.white, 0.28f); + var hair = new Color(0.22f, 0.13f, 0.08f, 1f); + var gold = new Color(1f, 0.78f, 0.26f, 1f); + + Ellipse(texture, 16, 5, 9, 3, new Color(0f, 0f, 0f, 0.35f)); + Fill(texture, 11, 6, 10, 3, outline); + Fill(texture, 12, 7, 8, 2, robeDark); + Fill(texture, 9, 9, 14, 13, outline); + Fill(texture, 10, 10, 12, 12, robeMid); + Fill(texture, 12, 11, 8, 10, robe); + Fill(texture, 15, 10, 2, 11, robeLight); + Fill(texture, 8, 12, 3, 8, outline); + Fill(texture, 21, 12, 3, 8, outline); + Fill(texture, 9, 13, 2, 6, robeDark); + Fill(texture, 21, 13, 2, 6, robeDark); + Set(texture, 14, 16, gold); + Set(texture, 17, 16, gold); + Fill(texture, 12, 21, 8, 5, outline); + Fill(texture, 13, 22, 6, 4, skin); + Fill(texture, 12, 25, 8, 3, hair); + Fill(texture, 10, 27, 12, 2, outline); + Fill(texture, 11, 28, 10, 1, robeDark); + Fill(texture, 13, 29, 6, 2, robe); + Set(texture, 13, 23, outline); + Set(texture, 18, 23, outline); + Set(texture, 16, 21, Shade(skin, 0.75f)); + Fill(texture, 11, 5, 4, 2, outline); + Fill(texture, 17, 5, 4, 2, outline); + Fill(texture, 12, 6, 3, 1, robeDark); + Fill(texture, 17, 6, 3, 1, robeDark); + } + + private static void DrawStation(Texture2D texture, Color element, Color accent) + { + var outline = new Color(0.035f, 0.032f, 0.045f, 1f); + var stone = new Color(0.46f, 0.48f, 0.54f, 1f); + var stoneDark = new Color(0.24f, 0.26f, 0.31f, 1f); + var stoneLight = new Color(0.66f, 0.68f, 0.74f, 1f); + var glow = Mix(element, Color.white, 0.22f); + + Ellipse(texture, 16, 5, 12, 4, new Color(0f, 0f, 0f, 0.36f)); + Diamond(texture, 16, 9, 13, 5, outline); + Diamond(texture, 16, 10, 11, 4, stoneDark); + Diamond(texture, 16, 12, 10, 4, stone); + Fill(texture, 8, 12, 16, 8, outline); + Fill(texture, 9, 13, 14, 7, stoneDark); + Fill(texture, 10, 16, 12, 3, stone); + Fill(texture, 11, 18, 10, 2, stoneLight); + Fill(texture, 11, 19, 10, 2, outline); + Fill(texture, 12, 20, 8, 2, stone); + Diamond(texture, 16, 24, 6, 6, outline); + Diamond(texture, 16, 24, 4, 5, element); + Diamond(texture, 16, 25, 2, 3, glow); + Set(texture, 15, 27, Color.white); + Set(texture, 20, 13, glow); + Set(texture, 11, 13, glow); + Line(texture, 10, 14, 22, 14, Shade(element, 0.75f)); + Set(texture, 7, 17, accent); + Set(texture, 24, 17, accent); + } + + private static void DrawTarget(Texture2D texture, Color primary, Color secondary) + { + var outline = new Color(0.035f, 0.032f, 0.045f, 1f); + var stone = new Color(0.42f, 0.43f, 0.48f, 1f); + var stoneLight = new Color(0.64f, 0.66f, 0.72f, 1f); + var core = Mix(primary, secondary, 0.35f); + + Ellipse(texture, 16, 5, 8, 3, new Color(0f, 0f, 0f, 0.34f)); + Fill(texture, 10, 6, 12, 3, outline); + Fill(texture, 11, 7, 10, 2, stone); + Fill(texture, 12, 9, 8, 8, outline); + Fill(texture, 13, 10, 6, 7, stoneLight); + Fill(texture, 12, 17, 8, 2, outline); + Fill(texture, 13, 18, 6, 2, stone); + Diamond(texture, 16, 23, 7, 7, outline); + Diamond(texture, 16, 23, 5, 5, core); + Diamond(texture, 16, 24, 2, 3, Mix(core, Color.white, 0.35f)); + Set(texture, 18, 26, Color.white); + Set(texture, 11, 14, Shade(core, 0.8f)); + Set(texture, 21, 14, Shade(core, 0.8f)); + } + + private static void DrawPulse(Texture2D texture, Color primary) + { + var glow = Mix(primary, Color.white, 0.35f); + Ring(texture, 16, 16, 11, 10, new Color(primary.r, primary.g, primary.b, 0.75f)); + Ring(texture, 16, 16, 7, 6, new Color(glow.r, glow.g, glow.b, 0.65f)); + Line(texture, 16, 3, 16, 29, new Color(glow.r, glow.g, glow.b, 0.42f)); + Line(texture, 3, 16, 29, 16, new Color(glow.r, glow.g, glow.b, 0.42f)); + Set(texture, 16, 16, Color.white); + } + + private static void DrawFloorTile(Texture2D texture, Color primary, Color secondary) + { + Fill(texture, 0, 0, Size, Size, primary); + Fill(texture, 0, 0, Size, 1, Shade(primary, 0.55f)); + Fill(texture, 0, 0, 1, Size, Shade(primary, 0.55f)); + Fill(texture, 0, 31, Size, 1, Mix(primary, Color.white, 0.08f)); + Fill(texture, 31, 0, 1, Size, Mix(primary, Color.white, 0.08f)); + Line(texture, 2, 15, 30, 15, secondary); + Line(texture, 15, 2, 15, 30, secondary); + Set(texture, 6, 8, Mix(primary, Color.white, 0.1f)); + Set(texture, 22, 5, Shade(primary, 0.68f)); + Set(texture, 25, 22, Mix(primary, Color.white, 0.08f)); + Set(texture, 8, 25, Shade(primary, 0.7f)); + Line(texture, 20, 18, 24, 20, Shade(primary, 0.55f)); + Line(texture, 7, 12, 10, 10, Mix(primary, Color.white, 0.08f)); + } + + private static void DrawWallTrim(Texture2D texture, Color primary, Color secondary) + { + Fill(texture, 0, 0, Size, Size, Shade(primary, 0.68f)); + Fill(texture, 0, 20, Size, 12, primary); + Fill(texture, 0, 17, Size, 3, secondary); + Fill(texture, 0, 15, Size, 2, Shade(primary, 0.42f)); + for (var x = 0; x < Size; x += 8) + { + Line(texture, x, 20, x + 3, 31, Shade(primary, 0.5f)); + Fill(texture, x + 1, 22, 2, 2, Mix(primary, Color.white, 0.12f)); + } + } + + private static void DrawRug(Texture2D texture, Color primary, Color secondary) + { + Fill(texture, 0, 0, Size, Size, Shade(primary, 0.55f)); + Fill(texture, 3, 0, 26, Size, primary); + Fill(texture, 5, 0, 2, Size, secondary); + Fill(texture, 25, 0, 2, Size, secondary); + Fill(texture, 9, 0, 14, Size, Shade(primary, 0.82f)); + for (var y = 3; y < Size; y += 8) + { + Diamond(texture, 16, y + 2, 5, 3, secondary); + Set(texture, 16, y + 2, Color.white); + } + Fill(texture, 0, 0, 3, Size, new Color(0f, 0f, 0f, 0.25f)); + Fill(texture, 29, 0, 3, Size, new Color(0f, 0f, 0f, 0.25f)); + } + + private static void DrawBookshelf(Texture2D texture, Color wood, Color accent) + { + var outline = new Color(0.04f, 0.025f, 0.018f, 1f); + Fill(texture, 5, 4, 22, 24, outline); + Fill(texture, 6, 5, 20, 22, wood); + Fill(texture, 7, 8, 18, 2, Shade(wood, 0.55f)); + Fill(texture, 7, 16, 18, 2, Shade(wood, 0.55f)); + Fill(texture, 7, 24, 18, 2, Shade(wood, 0.55f)); + var colors = new[] + { + accent, + new Color(0.85f, 0.18f, 0.22f, 1f), + new Color(0.18f, 0.48f, 0.9f, 1f), + new Color(0.92f, 0.72f, 0.22f, 1f), + new Color(0.45f, 0.78f, 0.38f, 1f) + }; + for (var shelf = 0; shelf < 3; shelf++) + { + for (var i = 0; i < 7; i++) + { + var color = colors[(shelf + i) % colors.Length]; + Fill(texture, 8 + i * 2, 10 + shelf * 8, 1, 5, color); + Set(texture, 8 + i * 2, 14 + shelf * 8, Shade(color, 0.6f)); + } + } + } + + private static void DrawCandle(Texture2D texture, Color metal, Color flame) + { + var outline = new Color(0.04f, 0.035f, 0.03f, 1f); + Ellipse(texture, 16, 4, 7, 2, new Color(0f, 0f, 0f, 0.3f)); + Fill(texture, 14, 5, 4, 14, outline); + Fill(texture, 15, 6, 2, 12, metal); + Fill(texture, 10, 12, 12, 2, outline); + Fill(texture, 11, 13, 10, 1, Shade(metal, 1.15f)); + Fill(texture, 9, 9, 3, 6, outline); + Fill(texture, 20, 9, 3, 6, outline); + Fill(texture, 10, 10, 1, 4, metal); + Fill(texture, 21, 10, 1, 4, metal); + Diamond(texture, 16, 22, 4, 6, Shade(flame, 0.85f)); + Diamond(texture, 16, 23, 2, 4, Mix(flame, Color.white, 0.45f)); + Set(texture, 16, 25, Color.white); + Set(texture, 15, 21, new Color(1f, 0.32f, 0.12f, 1f)); + } + + private static void DrawRuneCircle(Texture2D texture, Color primary, Color secondary) + { + Ring(texture, 16, 16, 13, 12, new Color(primary.r, primary.g, primary.b, 0.62f)); + Ring(texture, 16, 16, 9, 8, new Color(primary.r, primary.g, primary.b, 0.35f)); + Line(texture, 16, 4, 16, 28, new Color(secondary.r, secondary.g, secondary.b, 0.35f)); + Line(texture, 4, 16, 28, 16, new Color(secondary.r, secondary.g, secondary.b, 0.35f)); + Line(texture, 8, 8, 24, 24, new Color(primary.r, primary.g, primary.b, 0.25f)); + Line(texture, 8, 24, 24, 8, new Color(primary.r, primary.g, primary.b, 0.25f)); + Set(texture, 16, 29, secondary); + Set(texture, 16, 3, secondary); + Set(texture, 29, 16, secondary); + Set(texture, 3, 16, secondary); + } + + private static void Clear(Texture2D texture) + { + var clear = new Color(0f, 0f, 0f, 0f); + for (var y = 0; y < Size; y++) + { + for (var x = 0; x < Size; x++) + { + texture.SetPixel(x, y, clear); + } + } + } + + private static void Fill(Texture2D texture, int x, int y, int width, int height, Color color) + { + for (var yy = y; yy < y + height; yy++) + { + for (var xx = x; xx < x + width; xx++) + { + Set(texture, xx, yy, color); + } + } + } + + private static void Diamond(Texture2D texture, int centerX, int centerY, int radiusX, int radiusY, Color color) + { + for (var y = centerY - radiusY; y <= centerY + radiusY; y++) + { + for (var x = centerX - radiusX; x <= centerX + radiusX; x++) + { + var dx = Mathf.Abs(x - centerX) / (float)Mathf.Max(radiusX, 1); + var dy = Mathf.Abs(y - centerY) / (float)Mathf.Max(radiusY, 1); + if (dx + dy <= 1f) + { + Set(texture, x, y, color); + } + } + } + } + + private static void Ellipse(Texture2D texture, int centerX, int centerY, int radiusX, int radiusY, Color color) + { + for (var y = centerY - radiusY; y <= centerY + radiusY; y++) + { + for (var x = centerX - radiusX; x <= centerX + radiusX; x++) + { + var dx = (x - centerX) / (float)Mathf.Max(radiusX, 1); + var dy = (y - centerY) / (float)Mathf.Max(radiusY, 1); + if (dx * dx + dy * dy <= 1f) + { + Set(texture, x, y, color); + } + } + } + } + + private static void Ring(Texture2D texture, int centerX, int centerY, int outerRadius, int innerRadius, Color color) + { + var outer = outerRadius * outerRadius; + var inner = innerRadius * innerRadius; + for (var y = centerY - outerRadius; y <= centerY + outerRadius; y++) + { + for (var x = centerX - outerRadius; x <= centerX + outerRadius; x++) + { + var dx = x - centerX; + var dy = y - centerY; + var d = dx * dx + dy * dy; + if (d <= outer && d >= inner) + { + Set(texture, x, y, color); + } + } + } + } + + private static void Line(Texture2D texture, int x0, int y0, int x1, int y1, Color color) + { + var dx = Mathf.Abs(x1 - x0); + var dy = -Mathf.Abs(y1 - y0); + var sx = x0 < x1 ? 1 : -1; + var sy = y0 < y1 ? 1 : -1; + var error = dx + dy; + while (true) + { + Set(texture, x0, y0, color); + if (x0 == x1 && y0 == y1) + { + break; + } + + var e2 = 2 * error; + if (e2 >= dy) + { + error += dy; + x0 += sx; + } + + if (e2 <= dx) + { + error += dx; + y0 += sy; + } + } + } + + private static void Set(Texture2D texture, int x, int y, Color color) + { + if (x < 0 || y < 0 || x >= texture.width || y >= texture.height) + { + return; + } + + texture.SetPixel(x, y, color); + } + + private static Color Shade(Color color, float amount) + { + return new Color( + Mathf.Clamp01(color.r * amount), + Mathf.Clamp01(color.g * amount), + Mathf.Clamp01(color.b * amount), + color.a); + } + + private static Color Mix(Color a, Color b, float t) + { + return new Color( + Mathf.Lerp(a.r, b.r, t), + Mathf.Lerp(a.g, b.g, t), + Mathf.Lerp(a.b, b.b, t), + Mathf.Lerp(a.a, b.a, t)); + } + } + + public enum PixelSpriteKind + { + Player, + Station, + Target, + Pulse, + FloorTile, + WallTrim, + Rug, + Bookshelf, + Candle, + RuneCircle + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelArtFactory.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelArtFactory.cs.meta new file mode 100644 index 0000000..e9324bc --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelArtFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 05bf86aee60e8614f99af9db0a58efb6 \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelMaterialProvider.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelMaterialProvider.cs new file mode 100644 index 0000000..9cdc89e --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelMaterialProvider.cs @@ -0,0 +1,45 @@ +using UnityEngine; +using UnityEngine.UI; + +namespace MagicExamHall +{ + public static class PixelMaterialProvider + { + private const string SpriteMaterialPath = "MagicExamHallMaterials/PixelSpriteDefault"; + private const string UiMaterialPath = "MagicExamHallMaterials/PixelUIDefault"; + private static Material spriteMaterial; + private static Material uiMaterial; + + public static Material SpriteMaterial + { + get + { + if (spriteMaterial == null) + { + spriteMaterial = Resources.Load(SpriteMaterialPath) ?? CreateFallback("Sprites/Default"); + } + + return spriteMaterial; + } + } + + public static Material UiMaterial + { + get + { + if (uiMaterial == null) + { + uiMaterial = Resources.Load(UiMaterialPath) ?? Graphic.defaultGraphicMaterial ?? CreateFallback("UI/Default"); + } + + return uiMaterial; + } + } + + private static Material CreateFallback(string shaderName) + { + var shader = Shader.Find(shaderName); + return shader == null ? null : new Material(shader); + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelMaterialProvider.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelMaterialProvider.cs.meta new file mode 100644 index 0000000..0e680b0 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelMaterialProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d864db42c82c9f43a499793c6e909ac \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelSpriteView.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelSpriteView.cs new file mode 100644 index 0000000..9509ffa --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelSpriteView.cs @@ -0,0 +1,44 @@ +using System.Collections; +using UnityEngine; + +namespace MagicExamHall +{ + [RequireComponent(typeof(SpriteRenderer))] + public sealed class PixelSpriteView : MonoBehaviour + { + public PixelSpriteKind kind = PixelSpriteKind.Target; + public Color primary = Color.white; + public Color secondary = Color.gray; + public int sortingOrder; + public bool tiled; + public Vector2 tiledSize = Vector2.one; + + private IEnumerator Start() + { + yield return null; + Apply(); + } + + private void OnValidate() + { + if (Application.isPlaying) + { + Apply(); + } + } + + public void Apply() + { + var spriteRenderer = GetComponent(); + spriteRenderer.sprite = PixelArtFactory.CreateSprite(name, primary, secondary, kind); + spriteRenderer.sharedMaterial = PixelMaterialProvider.SpriteMaterial; + spriteRenderer.color = Color.white; + spriteRenderer.sortingOrder = sortingOrder; + spriteRenderer.drawMode = tiled ? SpriteDrawMode.Tiled : SpriteDrawMode.Simple; + if (tiled) + { + spriteRenderer.size = tiledSize; + } + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelSpriteView.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelSpriteView.cs.meta new file mode 100644 index 0000000..6991f9e --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/PixelSpriteView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41339a173814c09488f575732185ade5 \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs new file mode 100644 index 0000000..4cb57d2 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.EventSystems; + +namespace MagicExamHall +{ + public sealed class WorldDrawingController : MonoBehaviour + { + public Camera mainCamera = null!; + public float bufferSeconds = 0.8f; + public float minPointDistance = 0.06f; + public Color strokeColor = new(0.22f, 0.95f, 1f, 0.92f); + + private readonly List> bufferedStrokes = new(); + private readonly List activeStroke = new(); + private readonly List visuals = new(); + private bool drawing; + private bool waitingForBuffer; + private float lastReleaseTime; + + public event Action>, Vector2, int> SpellBuffered = delegate { }; + + public bool HasBufferedInput => bufferedStrokes.Count > 0 || activeStroke.Count > 0; + + private void Awake() + { + mainCamera ??= Camera.main; + } + + private void Update() + { + TickInput(); + TickBuffer(); + TickVisuals(); + } + + public void SubmitSyntheticSpell(List> strokes) + { + if (strokes == null || strokes.Count == 0) + { + SpellBuffered(new List>(), Vector2.zero, 0); + return; + } + + var copy = strokes.Select(stroke => stroke.Select(sample => new StrokeSample(sample.position, sample.time)).ToList()).ToList(); + SpellBuffered(copy, CenterOf(copy), copy.Count); + } + + private void TickInput() + { + if (mainCamera == null) + { + return; + } + + if (Input.GetMouseButtonDown(1) && !PointerIsOverUi()) + { + drawing = true; + waitingForBuffer = false; + activeStroke.Clear(); + AddPoint(Input.mousePosition); + } + + if (drawing && Input.GetMouseButton(1)) + { + AddPoint(Input.mousePosition); + } + + if (!drawing || !Input.GetMouseButtonUp(1)) + { + return; + } + + AddPoint(Input.mousePosition); + if (activeStroke.Count >= 2) + { + var stroke = new List(activeStroke); + bufferedStrokes.Add(stroke); + CreateStrokeVisual(stroke); + } + + activeStroke.Clear(); + drawing = false; + waitingForBuffer = true; + lastReleaseTime = Time.time; + } + + private void TickBuffer() + { + if (!waitingForBuffer || Time.time - lastReleaseTime < bufferSeconds) + { + return; + } + + Flush(); + } + + private void Flush() + { + waitingForBuffer = false; + if (bufferedStrokes.Count == 0) + { + return; + } + + var copy = bufferedStrokes.Select(stroke => stroke.Select(sample => new StrokeSample(sample.position, sample.time)).ToList()).ToList(); + bufferedStrokes.Clear(); + SpellBuffered(copy, CenterOf(copy), copy.Count); + } + + private void AddPoint(Vector2 screenPoint) + { + var world = mainCamera.ScreenToWorldPoint(new Vector3(screenPoint.x, screenPoint.y, -mainCamera.transform.position.z)); + var point = new Vector2(world.x, world.y); + if (activeStroke.Count > 0 && Vector2.Distance(activeStroke[^1].position, point) < minPointDistance) + { + return; + } + + activeStroke.Add(new StrokeSample(point, Time.time)); + } + + private bool PointerIsOverUi() + { + return EventSystem.current != null && EventSystem.current.IsPointerOverGameObject(); + } + + private void CreateStrokeVisual(IReadOnlyList stroke) + { + if (stroke.Count < 2) + { + return; + } + + var body = new GameObject("World Spell Stroke"); + body.transform.SetParent(transform, true); + var line = body.AddComponent(); + line.useWorldSpace = true; + line.positionCount = stroke.Count; + line.startWidth = 0.075f; + line.endWidth = 0.075f; + line.numCornerVertices = 0; + line.numCapVertices = 0; + line.material = new Material(Shader.Find("Sprites/Default")); + line.startColor = strokeColor; + line.endColor = strokeColor; + line.sortingOrder = 20; + for (var index = 0; index < stroke.Count; index++) + { + line.SetPosition(index, new Vector3(stroke[index].position.x, stroke[index].position.y, -0.2f)); + } + + visuals.Add(new StrokeVisual(body, line)); + } + + private void TickVisuals() + { + for (var index = visuals.Count - 1; index >= 0; index--) + { + var visual = visuals[index]; + visual.age += Time.deltaTime; + var alpha = Mathf.Lerp(0.92f, 0f, visual.age / 1.8f); + 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.body != null) + { + Destroy(visual.body); + } + visuals.RemoveAt(index); + } + } + } + + private static Vector2 CenterOf(IReadOnlyList> strokes) + { + var points = strokes.SelectMany(stroke => stroke).Select(sample => sample.position).ToList(); + return points.Count == 0 ? Vector2.zero : new Vector2(points.Average(point => point.x), points.Average(point => point.y)); + } + + private sealed class StrokeVisual + { + public readonly GameObject body; + public readonly LineRenderer line; + public float age; + + public StrokeVisual(GameObject body, LineRenderer line) + { + this.body = body; + this.line = line; + } + } + } +} diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs.meta b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs.meta new file mode 100644 index 0000000..001b5b8 --- /dev/null +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/WorldDrawingController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8cf276cd3d0b489cbb5c7b1f1bf9455e diff --git a/unity/MagicExamHall/Assets/Scenes.meta b/unity/MagicExamHall/Assets/Scenes.meta new file mode 100644 index 0000000..8acbebb --- /dev/null +++ b/unity/MagicExamHall/Assets/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e08aa8be451b9c14c96c958cf715d316 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Scenes/MagicExamHall.unity b/unity/MagicExamHall/Assets/Scenes/MagicExamHall.unity new file mode 100644 index 0000000..0d919a9 --- /dev/null +++ b/unity/MagicExamHall/Assets/Scenes/MagicExamHall.unity @@ -0,0 +1,1666 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 2 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &31954209 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 31954212} + - component: {fileID: 31954211} + - component: {fileID: 31954210} + m_Layer: 0 + m_Name: North Carved Wall + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &31954210 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 31954209} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 5 + primary: {r: 0.22, g: 0.2, b: 0.27, a: 1} + secondary: {r: 0.63, g: 0.5, b: 0.23, a: 1} + sortingOrder: -4 + tiled: 1 + tiledSize: {x: 16.4, y: 1.15} +--- !u!212 &31954211 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 31954209} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &31954212 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 31954209} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 4.95, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &122163910 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 122163913} + - component: {fileID: 122163912} + - component: {fileID: 122163911} + m_Layer: 0 + m_Name: South Carved Wall + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &122163911 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 122163910} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 5 + primary: {r: 0.18, g: 0.17, b: 0.22, a: 1} + secondary: {r: 0.5, g: 0.4, b: 0.2, a: 1} + sortingOrder: -4 + tiled: 1 + tiledSize: {x: 16.4, y: 0.8} +--- !u!212 &122163912 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 122163910} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &122163913 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 122163910} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: -4.95, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &351046477 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 351046480} + - component: {fileID: 351046479} + - component: {fileID: 351046478} + m_Layer: 0 + m_Name: Northeast Candelabra + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &351046478 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 351046477} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 8 + primary: {r: 0.63, g: 0.57, b: 0.44, a: 1} + secondary: {r: 1, g: 0.56, b: 0.15, a: 1} + sortingOrder: 2 + tiled: 0 + tiledSize: {x: 1, y: 1} +--- !u!212 &351046479 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 351046477} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &351046480 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 351046477} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 6.85, y: 3.65, z: 0} + m_LocalScale: {x: 0.9, y: 0.9, z: 0.9} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &504014085 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 504014088} + - component: {fileID: 504014087} + - component: {fileID: 504014086} + m_Layer: 0 + m_Name: EventSystem + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &504014086 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 504014085} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4f231c4fb786f3946a6b90b886c48677, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.StandaloneInputModule + m_SendPointerHoverToParent: 1 + m_HorizontalAxis: Horizontal + m_VerticalAxis: Vertical + m_SubmitButton: Submit + m_CancelButton: Cancel + m_InputActionsPerSecond: 10 + m_RepeatDelay: 0.5 + m_ForceModuleActive: 0 +--- !u!114 &504014087 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 504014085} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.EventSystem + m_FirstSelected: {fileID: 0} + m_sendNavigationEvents: 1 + m_DragThreshold: 10 +--- !u!4 &504014088 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 504014085} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &620383514 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 620383517} + - component: {fileID: 620383516} + - component: {fileID: 620383515} + m_Layer: 0 + m_Name: East Bookcase + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &620383515 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 620383514} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 7 + primary: {r: 0.42, g: 0.23, b: 0.12, a: 1} + secondary: {r: 0.68, g: 0.36, b: 0.86, a: 1} + sortingOrder: -1 + tiled: 0 + tiledSize: {x: 1, y: 1} +--- !u!212 &620383516 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 620383514} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &620383517 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 620383514} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 7.25, y: 1.25, z: 0} + m_LocalScale: {x: 1.25, y: 1.25, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &866057709 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 866057712} + - component: {fileID: 866057711} + - component: {fileID: 866057710} + m_Layer: 0 + m_Name: West Bookcase + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &866057710 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 866057709} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 7 + primary: {r: 0.42, g: 0.23, b: 0.12, a: 1} + secondary: {r: 0.42, g: 0.8, b: 0.88, a: 1} + sortingOrder: -1 + tiled: 0 + tiledSize: {x: 1, y: 1} +--- !u!212 &866057711 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 866057709} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &866057712 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 866057709} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -7.25, y: 1.25, z: 0} + m_LocalScale: {x: 1.25, y: 1.25, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &899282917 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 899282920} + - component: {fileID: 899282919} + - component: {fileID: 899282918} + m_Layer: 0 + m_Name: West Runner + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &899282918 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 899282917} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 6 + primary: {r: 0.14, g: 0.34, b: 0.44, a: 1} + secondary: {r: 0.8, g: 0.65, b: 0.32, a: 1} + sortingOrder: -5 + tiled: 1 + tiledSize: {x: 1.45, y: 4.8} +--- !u!212 &899282919 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 899282917} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &899282920 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 899282917} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -4.25, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1129889328 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1129889331} + - component: {fileID: 1129889330} + - component: {fileID: 1129889329} + m_Layer: 0 + m_Name: East Runner + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1129889329 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1129889328} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 6 + primary: {r: 0.14, g: 0.34, b: 0.44, a: 1} + secondary: {r: 0.8, g: 0.65, b: 0.32, a: 1} + sortingOrder: -5 + tiled: 1 + tiledSize: {x: 1.45, y: 4.8} +--- !u!212 &1129889330 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1129889328} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &1129889331 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1129889328} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 4.25, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1132326609 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1132326612} + - component: {fileID: 1132326611} + - component: {fileID: 1132326610} + m_Layer: 0 + m_Name: Center Runner + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1132326610 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1132326609} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 6 + primary: {r: 0.55, g: 0.1, b: 0.17, a: 1} + secondary: {r: 0.95, g: 0.69, b: 0.26, a: 1} + sortingOrder: -5 + tiled: 1 + tiledSize: {x: 2.2, y: 7.6} +--- !u!212 &1132326611 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1132326609} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &1132326612 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1132326609} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0.15, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1227248726 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1227248730} + - component: {fileID: 1227248727} + - component: {fileID: 1227248729} + - component: {fileID: 1227248728} + m_Layer: 0 + m_Name: Exam Canvas + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!223 &1227248727 +Canvas: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1227248726} + m_Enabled: 1 + serializedVersion: 3 + m_RenderMode: 0 + m_Camera: {fileID: 0} + m_PlaneDistance: 100 + m_PixelPerfect: 0 + m_ReceivesEvents: 1 + m_OverrideSorting: 0 + m_OverridePixelPerfect: 0 + m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 0 + m_AdditionalShaderChannelsFlag: 0 + m_UpdateRectTransformForStandalone: 0 + m_SortingLayerID: 0 + m_SortingOrder: 0 + m_TargetDisplay: 0 +--- !u!114 &1227248728 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1227248726} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.GraphicRaycaster + m_IgnoreReversedGraphics: 1 + m_BlockingObjects: 0 + m_BlockingMask: + serializedVersion: 2 + m_Bits: 4294967295 +--- !u!114 &1227248729 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1227248726} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.CanvasScaler + m_UiScaleMode: 1 + m_ReferencePixelsPerUnit: 100 + m_ScaleFactor: 1 + m_ReferenceResolution: {x: 1280, y: 720} + m_ScreenMatchMode: 0 + m_MatchWidthOrHeight: 0 + m_PhysicalUnit: 3 + m_FallbackScreenDPI: 96 + m_DefaultSpriteDPI: 96 + m_DynamicPixelsPerUnit: 1 + m_PresetInfoIsWorld: 0 +--- !u!224 &1227248730 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1227248726} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 0, y: 0, z: 0} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0, y: 0} +--- !u!1 &1469845223 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1469845225} + - component: {fileID: 1469845224} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!20 &1469845224 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1469845223} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.06, g: 0.08, b: 0.11, a: 1} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 1 + orthographic size: 6.2 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &1469845225 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1469845223} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: -10} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1602561624 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1602561627} + - component: {fileID: 1602561626} + - component: {fileID: 1602561625} + m_Layer: 0 + m_Name: Northwest Candelabra + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1602561625 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1602561624} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 8 + primary: {r: 0.63, g: 0.57, b: 0.44, a: 1} + secondary: {r: 1, g: 0.56, b: 0.15, a: 1} + sortingOrder: 2 + tiled: 0 + tiledSize: {x: 1, y: 1} +--- !u!212 &1602561626 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1602561624} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &1602561627 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1602561624} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -6.85, y: 3.65, z: 0} + m_LocalScale: {x: 0.9, y: 0.9, z: 0.9} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1712205759 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1712205760} + - component: {fileID: 1712205762} + - component: {fileID: 1712205761} + m_Layer: 0 + m_Name: Apprentice + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1712205760 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1712205759} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: -4.1, z: 0} + m_LocalScale: {x: 0.78, y: 0.78, z: 0.78} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1712205761 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1712205759} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 0 + primary: {r: 0.95, g: 0.92, b: 0.78, a: 1} + secondary: {r: 0.28, g: 0.62, b: 0.96, a: 1} + sortingOrder: 4 + tiled: 0 + tiledSize: {x: 1, y: 1} +--- !u!212 &1712205762 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1712205759} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!1 &1905287179 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1905287182} + - component: {fileID: 1905287181} + - component: {fileID: 1905287180} + m_Layer: 0 + m_Name: Exam Game Controller + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1905287180 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1905287179} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8cf276cd3d0b489cbb5c7b1f1bf9455e, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.WorldDrawingController + mainCamera: {fileID: 1469845224} + bufferSeconds: 0.8 + minPointDistance: 0.06 + strokeColor: {r: 0.22, g: 0.95, b: 1, a: 0.92} +--- !u!114 &1905287181 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1905287179} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1009b31849aa60743ab98b92f924506d, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.ExamGameController + mainCamera: {fileID: 1469845224} + player: {fileID: 1712205760} + canvas: {fileID: 1227248727} +--- !u!4 &1905287182 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1905287179} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &2075884583 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2075884586} + - component: {fileID: 2075884585} + - component: {fileID: 2075884584} + m_Layer: 0 + m_Name: Stone Tile Floor + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &2075884584 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2075884583} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 41339a173814c09488f575732185ade5, type: 3} + m_Name: + m_EditorClassIdentifier: MagicExamHall.Runtime::MagicExamHall.PixelSpriteView + kind: 4 + primary: {r: 0.16, g: 0.18, b: 0.23, a: 1} + secondary: {r: 0.1, g: 0.12, b: 0.16, a: 1} + sortingOrder: -7 + tiled: 1 + tiledSize: {x: 16.4, y: 10} +--- !u!212 &2075884585 +SpriteRenderer: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2075884583} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Sprite: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 0 + m_SpriteSortPoint: 0 +--- !u!4 &2075884586 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2075884583} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 1469845225} + - {fileID: 2075884586} + - {fileID: 31954212} + - {fileID: 122163913} + - {fileID: 1132326612} + - {fileID: 899282920} + - {fileID: 1129889331} + - {fileID: 866057712} + - {fileID: 620383517} + - {fileID: 1602561627} + - {fileID: 351046480} + - {fileID: 1712205760} + - {fileID: 1227248730} + - {fileID: 504014088} + - {fileID: 1905287182} diff --git a/unity/MagicExamHall/Assets/Scenes/MagicExamHall.unity.meta b/unity/MagicExamHall/Assets/Scenes/MagicExamHall.unity.meta new file mode 100644 index 0000000..f2ec33a --- /dev/null +++ b/unity/MagicExamHall/Assets/Scenes/MagicExamHall.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1a7ed358aaf44b3b83c312f61a57f401 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Tests.meta b/unity/MagicExamHall/Assets/Tests.meta new file mode 100644 index 0000000..965c637 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 234aee089a2befd4f9839aaba6b6b20b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Tests/EditMode.meta b/unity/MagicExamHall/Assets/Tests/EditMode.meta new file mode 100644 index 0000000..188adf4 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/EditMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: be604ce56e536c44eafb027f29df19dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs new file mode 100644 index 0000000..a16cda9 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs @@ -0,0 +1,249 @@ +using System.Collections.Generic; +using System.Linq; +using MagicExamHall; +using NUnit.Framework; +using UnityEngine; + +namespace MagicExamHall.Tests +{ + public sealed class GestureRecognizerTests + { + [TestCase(SpellFamily.Wind)] + [TestCase(SpellFamily.Earth)] + [TestCase(SpellFamily.Fire)] + [TestCase(SpellFamily.Water)] + [TestCase(SpellFamily.Life)] + public void CanonicalSamplesRecognizeTheirFamilies(SpellFamily family) + { + var strokes = GestureRecognizer.CreateCanonicalSamples(family); + var result = GestureRecognizer.Recognize(strokes, family); + + Assert.That(result.status, Is.EqualTo(RecognitionStatus.Recognized)); + Assert.That(result.recognizedFamily, Is.EqualTo(family)); + Assert.That(result.success, Is.True); + Assert.That(result.confidence, Is.GreaterThan(0.7f)); + } + + [TestCase(OverlayOperator.SteelBrace)] + [TestCase(OverlayOperator.ElectricFork)] + [TestCase(OverlayOperator.IceBar)] + [TestCase(OverlayOperator.SoulDot)] + [TestCase(OverlayOperator.VoidCut)] + [TestCase(OverlayOperator.MartialAxis)] + public void CanonicalOverlaySamplesRecognizeTheirOperators(OverlayOperator op) + { + var seal = CreateWorldSeal(op == OverlayOperator.MartialAxis ? new[] { OverlayOperator.VoidCut } : new OverlayOperator[0]); + var strokes = OverlayRecognizer.CreateCanonicalSamples(op, seal.worldCenter, seal.worldScale * 0.24f); + var result = OverlayRecognizer.Recognize(strokes, seal); + + Assert.That( + result.status, + Is.EqualTo(RecognitionStatus.Recognized), + $"score={result.score:0.000}, shape={result.shapeConfidence:0.000}, scale={result.scaleRatio:0.000}, anchor={result.anchorZone}, reason={result.feedbackReason}"); + Assert.That(result.recognizedOperator, Is.EqualTo(op)); + Assert.That(result.success, Is.True); + Assert.That(result.shapeConfidence, Is.GreaterThan(0.48f)); + } + + [Test] + public void MartialAxisRequiresVoidCutInSealStack() + { + var seal = CreateWorldSeal(); + var strokes = OverlayRecognizer.CreateCanonicalSamples(OverlayOperator.MartialAxis, seal.worldCenter, seal.worldScale * 0.24f); + 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.MartialAxis)); + Assert.That(result.feedbackReason, Does.Contain("절단").And.Contain("void_cut")); + } + + [Test] + public void OpenTriangleIsIncompleteInsteadOfFalsePositive() + { + var stroke = new List + { + new(new Vector2(220, 70), 0f), + new(new Vector2(400, 390), 0.12f), + new(new Vector2(80, 390), 0.24f) + }; + + var result = GestureRecognizer.Recognize(new List> { stroke }, SpellFamily.Fire); + + Assert.That(result.status, Is.Not.EqualTo(RecognitionStatus.Recognized)); + Assert.That(result.success, Is.False); + Assert.That(result.feedbackReason, Does.Contain("미완성").Or.Contain("닫힌")); + } + + [Test] + public void TwoLineWindIsIncomplete() + { + var strokes = new List> + { + MakeLine(70, 150, 390, 145, 0f), + MakeLine(70, 240, 390, 235, 0.2f) + }; + + var result = GestureRecognizer.Recognize(strokes, SpellFamily.Wind); + + Assert.That(result.status, Is.EqualTo(RecognitionStatus.Incomplete).Or.EqualTo(RecognitionStatus.Ambiguous)); + Assert.That(result.success, Is.False); + } + + [Test] + public void FastAndSlowFireKeepFamilyButChangeTempo() + { + var fast = GestureRecognizer.CreateCanonicalSamples(SpellFamily.Fire, timeStep: 0.01f); + var slow = GestureRecognizer.CreateCanonicalSamples(SpellFamily.Fire, timeStep: 0.08f); + + var fastResult = GestureRecognizer.Recognize(fast, SpellFamily.Fire); + var slowResult = GestureRecognizer.Recognize(slow, SpellFamily.Fire); + + Assert.That(fastResult.recognizedFamily, Is.EqualTo(SpellFamily.Fire)); + Assert.That(slowResult.recognizedFamily, Is.EqualTo(SpellFamily.Fire)); + Assert.That(fastResult.quality.tempo, Is.GreaterThan(slowResult.quality.tempo + 0.12f)); + } + + [Test] + public void LoggerWritesAttemptAndSurveyFiles() + { + var sessionId = "test-session-" + System.Guid.NewGuid().ToString("N"); + var logger = new ExamLogger(sessionId); + logger.LogAttempt(new AttemptLog + { + sessionId = sessionId, + trialId = "1-1", + targetFamily = "fire", + recognizedFamily = "fire", + status = RecognitionStatus.Recognized.ToString(), + confidence = 0.9f, + closure = 1f, + smoothness = 0.8f, + tempo = 0.7f, + stability = 0.9f, + rotationBias = 0.1f, + attemptIndex = 1, + elapsedMs = 1200, + feedbackViewed = true, + success = true, + hintShown = true, + assistLevel = 2, + assisted = true + }); + logger.LogSurvey(new SurveyLog + { + sessionId = sessionId, + clarity = 4, + fairness = 4, + feedbackHelpfulness = 5, + controlFeeling = 4, + immersion = 5, + comment = "clear", + completedTrials = 5, + totalAttempts = 6 + }); + + Assert.That(System.IO.File.Exists(System.IO.Path.Combine(logger.OutputDirectory, "attempts.csv")), Is.True); + Assert.That(System.IO.File.Exists(System.IO.Path.Combine(logger.OutputDirectory, "survey.csv")), Is.True); + var attemptsCsv = System.IO.File.ReadAllText(System.IO.Path.Combine(logger.OutputDirectory, "attempts.csv")); + Assert.That(attemptsCsv, Does.Contain("phase,baseFamily,overlayStack,sealId,floorId,targetObject,worldEffect")); + Assert.That(attemptsCsv, Does.Contain("hintShown,assistLevel,assisted")); + Assert.That(attemptsCsv, Does.Contain("true,2,true")); + } + + [Test] + public void RepeatedFailuresEscalateAssistLevel() + { + var failedResult = GestureRecognizer.Recognize(new List>(), SpellFamily.Fire); + + var firstFailure = HintAssistance.ForAttempt(SpellFamily.Fire, 0, false, failedResult); + var secondFailure = HintAssistance.ForAttempt(SpellFamily.Fire, 1, false, failedResult); + var thirdFailure = HintAssistance.ForAttempt(SpellFamily.Fire, 2, false, failedResult); + var laterFailure = HintAssistance.ForAttempt(SpellFamily.Fire, 5, false, failedResult); + + Assert.That(firstFailure.currentLevel, Is.EqualTo(AssistLevel.ReasonHint)); + Assert.That(secondFailure.currentLevel, Is.EqualTo(AssistLevel.Checklist)); + Assert.That(thirdFailure.currentLevel, Is.EqualTo(AssistLevel.GhostTrace)); + Assert.That(laterFailure.currentLevel, Is.EqualTo(AssistLevel.GhostTrace)); + } + + [Test] + public void SuccessAfterAssistIsLoggedAsAssisted() + { + var successfulResult = GestureRecognizer.Recognize(GestureRecognizer.CreateCanonicalSamples(SpellFamily.Fire), SpellFamily.Fire); + var hintState = HintAssistance.ForAttempt(SpellFamily.Fire, 2, true, successfulResult); + + Assert.That(hintState.assisted, Is.True); + Assert.That(hintState.hintShown, Is.True); + Assert.That(hintState.currentLevel, Is.EqualTo(AssistLevel.Checklist)); + + var sessionId = "assist-success-" + System.Guid.NewGuid().ToString("N"); + var logger = new ExamLogger(sessionId); + logger.LogAttempt(new AttemptLog + { + sessionId = sessionId, + trialId = "1-3", + targetFamily = "fire", + recognizedFamily = "fire", + status = successfulResult.status.ToString(), + confidence = successfulResult.confidence, + closure = successfulResult.quality.closure, + smoothness = successfulResult.quality.smoothness, + tempo = successfulResult.quality.tempo, + stability = successfulResult.quality.stability, + rotationBias = successfulResult.quality.rotationBias, + attemptIndex = 3, + elapsedMs = 3000, + feedbackViewed = true, + success = true, + hintShown = hintState.hintShown, + assistLevel = hintState.AssistLevelNumber, + assisted = hintState.assisted + }); + + var attemptsCsv = System.IO.File.ReadAllText(System.IO.Path.Combine(logger.OutputDirectory, "attempts.csv")); + Assert.That(attemptsCsv, Does.Contain("true,2,true")); + } + + [Test] + public void PreviewBeforeFailureDoesNotShowHint() + { + var preview = HintAssistance.PreviewFor(SpellFamily.Water, 0); + + Assert.That(preview.currentLevel, Is.EqualTo(AssistLevel.None)); + Assert.That(preview.hintShown, Is.False); + Assert.That(preview.assisted, Is.False); + } + + private static List MakeLine(float x1, float y1, float x2, float y2, float start) + { + return Enumerable.Range(0, 12) + .Select(index => + { + var t = index / 11f; + return new StrokeSample(new Vector2(Mathf.Lerp(x1, x2, t), Mathf.Lerp(y1, y2, t)), start + index * 0.02f); + }) + .ToList(); + } + + private static CompiledSeal CreateWorldSeal(params OverlayOperator[] overlays) + { + var baseSamples = Offset(GestureRecognizer.CreateCanonicalSamples(SpellFamily.Fire, 1.6f, 0.03f), Vector2.zero, 0.8f); + var baseResult = SpellRuntime.RecognizeBase(baseSamples); + var seal = SpellRuntime.CreateSeal(baseResult, 0f); + foreach (var op in overlays) + { + seal.overlayStack.Add(op); + } + + return seal; + } + + private static List> Offset(List> strokes, Vector2 center, float canonicalCenter) + { + return strokes + .Select(stroke => stroke.Select(sample => new StrokeSample(sample.position - Vector2.one * canonicalCenter + center, sample.time)).ToList()) + .ToList(); + } + } +} diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs.meta b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs.meta new file mode 100644 index 0000000..c0b71f4 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/EditMode/GestureRecognizerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fd325daee9bf9e458ae22ed05d3750a \ No newline at end of file diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/MagicExamHall.EditModeTests.asmdef b/unity/MagicExamHall/Assets/Tests/EditMode/MagicExamHall.EditModeTests.asmdef new file mode 100644 index 0000000..97a7fbc --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/EditMode/MagicExamHall.EditModeTests.asmdef @@ -0,0 +1,21 @@ +{ + "name": "MagicExamHall.EditModeTests", + "rootNamespace": "MagicExamHall.Tests", + "references": [ + "MagicExamHall.Runtime" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/unity/MagicExamHall/Assets/Tests/EditMode/MagicExamHall.EditModeTests.asmdef.meta b/unity/MagicExamHall/Assets/Tests/EditMode/MagicExamHall.EditModeTests.asmdef.meta new file mode 100644 index 0000000..8a3d154 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/EditMode/MagicExamHall.EditModeTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c68585ed45ee99e4fa26e4b9f8cd87ae +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode.meta b/unity/MagicExamHall/Assets/Tests/PlayMode.meta new file mode 100644 index 0000000..25a2dcc --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: caf78704ec1cef9408f1f877ed3a0c03 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHall.PlayModeTests.asmdef b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHall.PlayModeTests.asmdef new file mode 100644 index 0000000..2d5bf5b --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHall.PlayModeTests.asmdef @@ -0,0 +1,19 @@ +{ + "name": "MagicExamHall.PlayModeTests", + "rootNamespace": "MagicExamHall.Tests", + "references": [ + "MagicExamHall.Runtime" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHall.PlayModeTests.asmdef.meta b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHall.PlayModeTests.asmdef.meta new file mode 100644 index 0000000..8de956c --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHall.PlayModeTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d458ea831916f43459ccc68350faa908 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs new file mode 100644 index 0000000..ce7a9a6 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs @@ -0,0 +1,152 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MagicExamHall; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using UnityEngine.UI; + +namespace MagicExamHall.Tests +{ + public sealed class MagicExamHallSceneSmokeTests + { + [UnityTest] + public IEnumerator SceneLoadsWithWorldCastingGameObjects() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + + Assert.That(controller, Is.Not.Null); + Assert.That(controller.FloorCount, Is.EqualTo(5)); + Assert.That(controller.CurrentFloorNumber, Is.EqualTo(1)); + 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); + Assert.That(controller.OutputDirectory, Does.Contain("MagicExamHallLogs")); + } + + [UnityTest] + public IEnumerator SyntheticBaseCastCreatesWorldSealWithoutPanel() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + var result = controller.CastSyntheticBaseForTests(SpellFamily.Fire, new Vector2(-5.5f, 2.6f)); + yield return null; + + 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.IsDrawingPanelVisible, Is.False); + Assert.That(controller.IsResultPanelVisible, Is.False); + } + + [UnityTest] + public IEnumerator OverlayAttachesToSealStack() + { + 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); + controller.CastSyntheticOverlayForTests(OverlayOperator.VoidCut, Vector2.zero); + controller.CastSyntheticOverlayForTests(OverlayOperator.MartialAxis, Vector2.zero); + yield return null; + + Assert.That(controller.LastOverlayStack.Contains(OverlayOperator.VoidCut), Is.True); + Assert.That(controller.LastOverlayStack.Contains(OverlayOperator.MartialAxis), Is.True); + } + + [UnityTest] + public IEnumerator FailedBaseCastsEscalateMagicNoteHints() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.CastRawBaseForTests(new List>(), Vector2.zero); + yield return null; + Assert.That(controller.CurrentAssistLevel, Is.EqualTo(1)); + Assert.That(controller.LastMagicNoteText, Does.Contain("짧은 힌트")); + + controller.CastRawBaseForTests(new List>(), Vector2.zero); + yield return null; + Assert.That(controller.CurrentAssistLevel, Is.EqualTo(2)); + Assert.That(controller.LastMagicNoteText, Does.Contain("체크리스트")); + + controller.CastRawBaseForTests(new List>(), Vector2.zero); + yield return null; + Assert.That(controller.CurrentAssistLevel, Is.EqualTo(3)); + Assert.That(controller.LastMagicNoteText, Does.Contain("강한 보조")); + Assert.That(controller.LastHintText, Does.Contain("바람")); + } + + [UnityTest] + public IEnumerator SuccessAfterBaseHintKeepsAssistedFeedback() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.CastRawBaseForTests(new List>(), Vector2.zero); + controller.CastRawBaseForTests(new List>(), Vector2.zero); + var result = controller.CastSyntheticBaseForTests(SpellFamily.Wind, new Vector2(5.5f, 2.6f)); + yield return null; + + Assert.That(result.spell.status, Is.EqualTo(RecognitionStatus.Recognized)); + Assert.That(controller.CurrentAssistLevel, Is.EqualTo(2)); + Assert.That(controller.LastMagicNoteText, Does.Contain("이전 힌트")); + Assert.That(controller.ActiveSealCount, Is.EqualTo(1)); + } + + [UnityTest] + public IEnumerator FloorTransitionsHazardResetAndEndingReportWork() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.CompleteCurrentFloorForTests(); + controller.AdvanceFloorForTests(); + yield return null; + Assert.That(controller.CurrentFloorNumber, Is.EqualTo(2)); + + controller.LoadFloorForTests(3); + controller.MovePlayerForTests(new Vector2(-3.1f, -0.4f)); + yield return null; + Assert.That(Vector2.Distance(controller.PlayerPosition, new Vector2(0f, -4.05f)), Is.LessThan(0.2f)); + + for (var index = controller.CurrentFloorNumber; index <= controller.FloorCount; index++) + { + controller.CompleteCurrentFloorForTests(); + controller.AdvanceFloorForTests(); + yield return null; + } + + Assert.That(controller.HasEndingReport, Is.True); + } + } +} diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs.meta b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs.meta new file mode 100644 index 0000000..0d7c0f5 --- /dev/null +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b01b71a64ef24045a64e433239c18e8 \ No newline at end of file diff --git a/unity/MagicExamHall/Packages/manifest.json b/unity/MagicExamHall/Packages/manifest.json new file mode 100644 index 0000000..cc2d80d --- /dev/null +++ b/unity/MagicExamHall/Packages/manifest.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "com.unity.test-framework": "1.6.0", + "com.unity.ugui": "1.0.0", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.physics2d": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + } +} diff --git a/unity/MagicExamHall/Packages/packages-lock.json b/unity/MagicExamHall/Packages/packages-lock.json new file mode 100644 index 0000000..ca48601 --- /dev/null +++ b/unity/MagicExamHall/Packages/packages-lock.json @@ -0,0 +1,53 @@ +{ + "dependencies": { + "com.unity.ext.nunit": { + "version": "2.0.5", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, + "com.unity.test-framework": { + "version": "1.6.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.ext.nunit": "2.0.3", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + } + }, + "com.unity.ugui": { + "version": "2.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.imgui": "1.0.0" + } + }, + "com.unity.modules.imgui": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.jsonserialize": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.physics2d": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.ui": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + } + } +} diff --git a/unity/MagicExamHall/ProjectSettings/AudioManager.asset b/unity/MagicExamHall/ProjectSettings/AudioManager.asset new file mode 100644 index 0000000..50b4625 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/AudioManager.asset @@ -0,0 +1,23 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!11 &1 +AudioManager: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Volume: 1 + Rolloff Scale: 1 + Doppler Factor: 1 + Default Speaker Mode: 2 + m_SampleRate: 0 + m_DSPBufferSize: 1024 + m_VirtualVoiceCount: 512 + m_RealVoiceCount: 32 + m_EnableOutputSuspension: 1 + m_SpatializerPlugin: + m_AmbisonicDecoderPlugin: + m_DisableAudio: 0 + m_VirtualizeEffects: 1 + m_RequestedDSPBufferSize: 0 + m_AudioFoundation: 0 + m_OutputChannelLayout: 2 + m_OutputSamplingRate: 48000 diff --git a/unity/MagicExamHall/ProjectSettings/ClusterInputManager.asset b/unity/MagicExamHall/ProjectSettings/ClusterInputManager.asset new file mode 100644 index 0000000..e7886b2 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/ClusterInputManager.asset @@ -0,0 +1,6 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!236 &1 +ClusterInputManager: + m_ObjectHideFlags: 0 + m_Inputs: [] diff --git a/unity/MagicExamHall/ProjectSettings/DynamicsManager.asset b/unity/MagicExamHall/ProjectSettings/DynamicsManager.asset new file mode 100644 index 0000000..3c102d1 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/DynamicsManager.asset @@ -0,0 +1,45 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!55 &1 +PhysicsManager: + m_ObjectHideFlags: 0 + serializedVersion: 23 + m_Gravity: {x: 0, y: -9.81, z: 0} + m_DefaultMaterial: {fileID: 0} + m_BounceThreshold: 2 + m_DefaultMaxDepenetrationVelocity: 10 + m_SleepThreshold: 0.005 + m_DefaultContactOffset: 0.01 + m_DefaultSolverIterations: 6 + m_DefaultSolverVelocityIterations: 1 + m_QueriesHitBackfaces: 0 + m_QueriesHitTriggers: 1 + m_EnableAdaptiveForce: 0 + m_ClothInterCollisionDistance: 0.1 + m_ClothInterCollisionStiffness: 0.2 + m_LayerCollisionMatrix: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + m_SimulationMode: 0 + m_AutoSyncTransforms: 0 + m_ReuseCollisionCallbacks: 1 + m_InvokeCollisionCallbacks: 1 + m_ClothInterCollisionSettingsToggle: 0 + m_ClothGravity: {x: 0, y: -9.81, z: 0} + m_ContactPairsMode: 0 + m_BroadphaseType: 2 + m_WorldBounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 256, y: 256, z: 256} + m_WorldSubdivisions: 8 + m_FrictionType: 0 + m_EnableEnhancedDeterminism: 0 + m_ImprovedPatchFriction: 0 + m_GenerateOnTriggerStayEvents: 1 + m_SolverType: 0 + m_DefaultMaxAngularSpeed: 50 + m_ScratchBufferChunkCount: 4 + m_CurrentBackendId: 4072204805 + m_FastMotionThreshold: 3.4028235e+38 + m_SceneBuffersReleaseInterval: 0 + m_ReleaseSceneBuffers: 0 + m_LogVerbosity: 3 + m_IncrementalStaticBroadphase: 1 diff --git a/unity/MagicExamHall/ProjectSettings/EditorBuildSettings.asset b/unity/MagicExamHall/ProjectSettings/EditorBuildSettings.asset new file mode 100644 index 0000000..ab6d8d6 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/EditorBuildSettings.asset @@ -0,0 +1,12 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1045 &1 +EditorBuildSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Scenes: + - enabled: 1 + path: Assets/Scenes/MagicExamHall.unity + guid: 1a7ed358aaf44b3b83c312f61a57f401 + m_configObjects: {} + m_UseUCBPForAssetBundles: 0 diff --git a/unity/MagicExamHall/ProjectSettings/EditorSettings.asset b/unity/MagicExamHall/ProjectSettings/EditorSettings.asset new file mode 100644 index 0000000..c3aef21 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/EditorSettings.asset @@ -0,0 +1,50 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!159 &1 +EditorSettings: + m_ObjectHideFlags: 0 + serializedVersion: 15 + m_SerializationMode: 2 + m_LineEndingsForNewScripts: 2 + m_DefaultBehaviorMode: 0 + m_PrefabRegularEnvironment: {fileID: 0} + m_PrefabUIEnvironment: {fileID: 0} + m_SpritePackerMode: 0 + m_SpritePackerCacheSize: 10 + m_SpritePackerPaddingPower: 1 + m_Bc7TextureCompressor: 0 + m_EtcTextureCompressorBehavior: 1 + m_EtcTextureFastCompressor: 1 + m_EtcTextureNormalCompressor: 2 + m_EtcTextureBestCompressor: 4 + m_ProjectGenerationIncludedExtensions: txt;xml;fnt;cd;asmdef;asmref;rsp;java;cpp;c;mm;m;h + m_ProjectGenerationRootNamespace: + m_EnableTextureStreamingInEditMode: 1 + m_EnableTextureStreamingInPlayMode: 1 + m_EnableEditorAsyncCPUTextureLoading: 0 + m_AsyncShaderCompilation: 1 + m_PrefabModeAllowAutoSave: 1 + m_EnterPlayModeOptionsEnabled: 1 + m_EnterPlayModeOptions: 0 + m_GameObjectNamingDigits: 1 + m_GameObjectNamingScheme: 0 + m_AssetNamingUsesSpace: 1 + m_InspectorUseIMGUIDefaultInspector: 0 + m_UseLegacyProbeSampleCount: 0 + m_SerializeInlineMappingsOnOneLine: 1 + m_DisableCookiesInLightmapper: 0 + m_ShadowmaskStitching: 1 + m_AssetPipelineMode: 1 + m_RefreshImportMode: 0 + m_CacheServerMode: 0 + m_CacheServerEndpoint: + m_CacheServerNamespacePrefix: default + m_CacheServerEnableDownload: 1 + m_CacheServerEnableUpload: 1 + m_CacheServerEnableAuth: 0 + m_CacheServerEnableTls: 0 + m_CacheServerValidationMode: 2 + m_CacheServerDownloadBatchSize: 128 + m_EnableEnlightenBakedGI: 0 + m_ReferencedClipsExactNaming: 1 + m_ForceAssetUnloadAndGCOnSceneLoad: 1 diff --git a/unity/MagicExamHall/ProjectSettings/GraphicsSettings.asset b/unity/MagicExamHall/ProjectSettings/GraphicsSettings.asset new file mode 100644 index 0000000..9c8f1b0 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/GraphicsSettings.asset @@ -0,0 +1,61 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!30 &1 +GraphicsSettings: + m_ObjectHideFlags: 0 + serializedVersion: 16 + m_Deferred: + m_Mode: 1 + m_Shader: {fileID: 0} + m_DeferredReflections: + m_Mode: 1 + m_Shader: {fileID: 0} + m_ScreenSpaceShadows: + m_Mode: 1 + m_Shader: {fileID: 0} + m_DepthNormals: + m_Mode: 1 + m_Shader: {fileID: 0} + m_MotionVectors: + m_Mode: 1 + m_Shader: {fileID: 0} + m_LightHalo: + m_Mode: 1 + m_Shader: {fileID: 0} + m_LensFlare: + m_Mode: 1 + m_Shader: {fileID: 0} + m_VideoShadersIncludeMode: 2 + m_AlwaysIncludedShaders: [] + m_PreloadedShaders: [] + m_PreloadShadersBatchTimeLimit: -1 + m_SpritesDefaultMaterial: {fileID: 0} + m_CustomRenderPipeline: {fileID: 0} + m_TransparencySortMode: 0 + m_TransparencySortAxis: {x: 0, y: 0, z: 1} + m_DefaultRenderingPath: 1 + m_DefaultMobileRenderingPath: 1 + m_TierSettings: [] + m_LightmapStripping: 0 + m_FogStripping: 0 + m_InstancingStripping: 0 + m_BrgStripping: 0 + m_LightmapKeepPlain: 1 + m_LightmapKeepDirCombined: 1 + m_LightmapKeepDynamicPlain: 1 + m_LightmapKeepDynamicDirCombined: 1 + m_LightmapKeepShadowMask: 1 + m_LightmapKeepSubtractive: 1 + m_FogKeepLinear: 1 + m_FogKeepExp: 1 + m_FogKeepExp2: 1 + m_AlbedoSwatchInfos: [] + m_RenderPipelineGlobalSettingsMap: {} + m_ShaderBuildSettings: + keywordDeclarationOverrides: [] + m_LightsUseLinearIntensity: 0 + m_LightsUseColorTemperature: 0 + m_LogWhenShaderIsCompiled: 0 + m_LightProbeOutsideHullStrategy: 1 + m_CameraRelativeLightCulling: 0 + m_CameraRelativeShadowCulling: 0 diff --git a/unity/MagicExamHall/ProjectSettings/InputManager.asset b/unity/MagicExamHall/ProjectSettings/InputManager.asset new file mode 100644 index 0000000..8068b20 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/InputManager.asset @@ -0,0 +1,296 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!13 &1 +InputManager: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Axes: + - serializedVersion: 3 + m_Name: Horizontal + descriptiveName: + descriptiveNegativeName: + negativeButton: left + positiveButton: right + altNegativeButton: a + altPositiveButton: d + gravity: 3 + dead: 0.001 + sensitivity: 3 + snap: 1 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Vertical + descriptiveName: + descriptiveNegativeName: + negativeButton: down + positiveButton: up + altNegativeButton: s + altPositiveButton: w + gravity: 3 + dead: 0.001 + sensitivity: 3 + snap: 1 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Fire1 + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: left ctrl + altNegativeButton: + altPositiveButton: mouse 0 + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Fire2 + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: left alt + altNegativeButton: + altPositiveButton: mouse 1 + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Fire3 + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: left shift + altNegativeButton: + altPositiveButton: mouse 2 + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Jump + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: space + altNegativeButton: + altPositiveButton: + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Mouse X + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: + altNegativeButton: + altPositiveButton: + gravity: 0 + dead: 0 + sensitivity: 0.1 + snap: 0 + invert: 0 + type: 1 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Mouse Y + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: + altNegativeButton: + altPositiveButton: + gravity: 0 + dead: 0 + sensitivity: 0.1 + snap: 0 + invert: 0 + type: 1 + axis: 1 + joyNum: 0 + - serializedVersion: 3 + m_Name: Mouse ScrollWheel + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: + altNegativeButton: + altPositiveButton: + gravity: 0 + dead: 0 + sensitivity: 0.1 + snap: 0 + invert: 0 + type: 1 + axis: 2 + joyNum: 0 + - serializedVersion: 3 + m_Name: Horizontal + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: + altNegativeButton: + altPositiveButton: + gravity: 0 + dead: 0.19 + sensitivity: 1 + snap: 0 + invert: 0 + type: 2 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Vertical + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: + altNegativeButton: + altPositiveButton: + gravity: 0 + dead: 0.19 + sensitivity: 1 + snap: 0 + invert: 1 + type: 2 + axis: 1 + joyNum: 0 + - serializedVersion: 3 + m_Name: Fire1 + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: joystick button 0 + altNegativeButton: + altPositiveButton: + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Fire2 + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: joystick button 1 + altNegativeButton: + altPositiveButton: + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Fire3 + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: joystick button 2 + altNegativeButton: + altPositiveButton: + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Jump + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: joystick button 3 + altNegativeButton: + altPositiveButton: + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Submit + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: return + altNegativeButton: + altPositiveButton: joystick button 0 + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Submit + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: enter + altNegativeButton: + altPositiveButton: space + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + - serializedVersion: 3 + m_Name: Cancel + descriptiveName: + descriptiveNegativeName: + negativeButton: + positiveButton: escape + altNegativeButton: + altPositiveButton: joystick button 1 + gravity: 1000 + dead: 0.001 + sensitivity: 1000 + snap: 0 + invert: 0 + type: 0 + axis: 0 + joyNum: 0 + m_UsePhysicalKeys: 1 diff --git a/unity/MagicExamHall/ProjectSettings/MemorySettings.asset b/unity/MagicExamHall/ProjectSettings/MemorySettings.asset new file mode 100644 index 0000000..5b5face --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/MemorySettings.asset @@ -0,0 +1,35 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!387306366 &1 +MemorySettings: + m_ObjectHideFlags: 0 + m_EditorMemorySettings: + m_MainAllocatorBlockSize: -1 + m_ThreadAllocatorBlockSize: -1 + m_MainGfxBlockSize: -1 + m_ThreadGfxBlockSize: -1 + m_CacheBlockSize: -1 + m_TypetreeBlockSize: -1 + m_ProfilerBlockSize: -1 + m_ProfilerEditorBlockSize: -1 + m_BucketAllocatorGranularity: -1 + m_BucketAllocatorBucketsCount: -1 + m_BucketAllocatorBlockSize: -1 + m_BucketAllocatorBlockCount: -1 + m_ProfilerBucketAllocatorGranularity: -1 + m_ProfilerBucketAllocatorBucketsCount: -1 + m_ProfilerBucketAllocatorBlockSize: -1 + m_ProfilerBucketAllocatorBlockCount: -1 + m_TempAllocatorSizeMain: -1 + m_JobTempAllocatorBlockSize: -1 + m_BackgroundJobTempAllocatorBlockSize: -1 + m_JobTempAllocatorReducedBlockSize: -1 + m_TempAllocatorSizeGIBakingWorker: -1 + m_TempAllocatorSizeNavMeshWorker: -1 + m_TempAllocatorSizeAudioWorker: -1 + m_TempAllocatorSizeCloudWorker: -1 + m_TempAllocatorSizeGfx: -1 + m_TempAllocatorSizeJobWorker: -1 + m_TempAllocatorSizeBackgroundWorker: -1 + m_TempAllocatorSizePreloadManager: -1 + m_PlatformMemorySettings: {} diff --git a/unity/MagicExamHall/ProjectSettings/MultiplayerManager.asset b/unity/MagicExamHall/ProjectSettings/MultiplayerManager.asset new file mode 100644 index 0000000..c19bcd7 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/MultiplayerManager.asset @@ -0,0 +1,9 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!655991488 &1 +MultiplayerManager: + m_ObjectHideFlags: 0 + m_EnableMultiplayerRoles: 0 + m_EnablePlayModeLocalDeployment: 0 + m_EnablePlayModeRemoteDeployment: 0 + m_StrippingTypes: {} diff --git a/unity/MagicExamHall/ProjectSettings/NavMeshAreas.asset b/unity/MagicExamHall/ProjectSettings/NavMeshAreas.asset new file mode 100644 index 0000000..2e2e369 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/NavMeshAreas.asset @@ -0,0 +1,93 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!126 &1 +NavMeshProjectSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + areas: + - name: Walkable + cost: 1 + - name: Not Walkable + cost: 1 + - name: Jump + cost: 2 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + - name: + cost: 1 + m_LastAgentTypeID: -887442657 + m_Settings: + - serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.75 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_SettingNames: + - Humanoid diff --git a/unity/MagicExamHall/ProjectSettings/Physics2DSettings.asset b/unity/MagicExamHall/ProjectSettings/Physics2DSettings.asset new file mode 100644 index 0000000..14f419f --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/Physics2DSettings.asset @@ -0,0 +1,57 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!19 &1 +Physics2DSettings: + m_ObjectHideFlags: 0 + serializedVersion: 11 + m_Gravity: {x: 0, y: -9.81} + m_DefaultMaterial: {fileID: 0} + m_VelocityIterations: 8 + m_PositionIterations: 3 + m_BounceThreshold: 1 + m_MaxLinearCorrection: 0.2 + m_MaxAngularCorrection: 8 + m_MaxTranslationSpeed: 100 + m_MaxRotationSpeed: 360 + m_BaumgarteScale: 0.2 + m_BaumgarteTimeOfImpactScale: 0.75 + m_TimeToSleep: 0.5 + m_LinearSleepTolerance: 0.01 + m_AngularSleepTolerance: 2 + m_DefaultContactOffset: 0.01 + m_ContactThreshold: 0 + m_JobOptions: + serializedVersion: 2 + useMultithreading: 0 + useConsistencySorting: 0 + m_InterpolationPosesPerJob: 100 + m_NewContactsPerJob: 30 + m_CollideContactsPerJob: 100 + m_ClearFlagsPerJob: 200 + m_ClearBodyForcesPerJob: 200 + m_SyncDiscreteFixturesPerJob: 50 + m_SyncContinuousFixturesPerJob: 50 + m_FindNearestContactsPerJob: 100 + m_UpdateTriggerContactsPerJob: 100 + m_IslandSolverCostThreshold: 100 + m_IslandSolverBodyCostScale: 1 + m_IslandSolverContactCostScale: 10 + m_IslandSolverJointCostScale: 10 + m_IslandSolverBodiesPerJob: 50 + m_IslandSolverContactsPerJob: 50 + m_SimulationMode: 0 + m_SimulationLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_MaxSubStepCount: 4 + m_MinSubStepFPS: 30 + m_UseSubStepping: 0 + m_UseSubStepContacts: 0 + m_QueriesHitTriggers: 1 + m_QueriesStartInColliders: 1 + m_CallbacksOnDisable: 1 + m_ReuseCollisionCallbacks: 1 + m_AutoSyncTransforms: 0 + m_GizmoOptions: 10 + m_LayerCollisionMatrix: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + m_PhysicsLowLevelSettings: {fileID: 0} diff --git a/unity/MagicExamHall/ProjectSettings/PresetManager.asset b/unity/MagicExamHall/ProjectSettings/PresetManager.asset new file mode 100644 index 0000000..67a94da --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/PresetManager.asset @@ -0,0 +1,7 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1386491679 &1 +PresetManager: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_DefaultPresets: {} diff --git a/unity/MagicExamHall/ProjectSettings/ProjectSettings.asset b/unity/MagicExamHall/ProjectSettings/ProjectSettings.asset new file mode 100644 index 0000000..31516d2 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/ProjectSettings.asset @@ -0,0 +1,792 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!129 &1 +PlayerSettings: + m_ObjectHideFlags: 0 + serializedVersion: 28 + productGUID: f754ceaa3b3d70a499816fabd4bb7f75 + AndroidProfiler: 0 + AndroidFilterTouchesWhenObscured: 0 + AndroidEnableSustainedPerformanceMode: 0 + defaultScreenOrientation: 4 + targetDevice: 2 + useOnDemandResources: 0 + accelerometerFrequency: 60 + companyName: DefaultCompany + productName: MagicExamHall + defaultCursor: {fileID: 0} + cursorHotspot: {x: 0, y: 0} + m_SplashScreenBackgroundColor: {r: 0.12156863, g: 0.12156863, b: 0.1254902, a: 1} + m_ShowUnitySplashScreen: 1 + m_ShowUnitySplashLogo: 1 + m_SplashScreenOverlayOpacity: 1 + m_SplashScreenAnimation: 1 + m_SplashScreenLogoStyle: 1 + m_SplashScreenDrawMode: 0 + m_SplashScreenBackgroundAnimationZoom: 1 + m_SplashScreenLogoAnimationZoom: 1 + m_SplashScreenBackgroundLandscapeAspect: 1 + m_SplashScreenBackgroundPortraitAspect: 1 + m_SplashScreenBackgroundLandscapeUvs: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + m_SplashScreenBackgroundPortraitUvs: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + m_SplashScreenLogos: [] + m_VirtualRealitySplashScreen: {fileID: 0} + m_HolographicTrackingLossScreen: {fileID: 0} + defaultScreenWidth: 1920 + defaultScreenHeight: 1080 + defaultScreenWidthWeb: 960 + defaultScreenHeightWeb: 600 + m_StereoRenderingPath: 0 + m_ActiveColorSpace: 0 + unsupportedMSAAFallback: 0 + m_SpriteBatchMaxVertexCount: 65535 + m_SpriteBatchVertexThreshold: 300 + m_MTRendering: 1 + mipStripping: 0 + numberOfMipsStripped: 0 + numberOfMipsStrippedPerMipmapLimitGroup: {} + m_StackTraceTypes: 010000000100000001000000010000000100000001000000 + iosShowActivityIndicatorOnLoading: -1 + androidShowActivityIndicatorOnLoading: -1 + iosUseCustomAppBackgroundBehavior: 0 + allowedAutorotateToPortrait: 1 + allowedAutorotateToPortraitUpsideDown: 1 + allowedAutorotateToLandscapeRight: 1 + allowedAutorotateToLandscapeLeft: 1 + useOSAutorotation: 1 + use32BitDisplayBuffer: 1 + preserveFramebufferAlpha: 0 + disableDepthAndStencilBuffers: 0 + androidStartInFullscreen: 1 + androidRenderOutsideSafeArea: 1 + androidUseSwappy: 1 + androidDisplayOptions: 1 + androidBlitType: 0 + androidResizeableActivity: 1 + androidDefaultWindowWidth: 1920 + androidDefaultWindowHeight: 1080 + androidMinimumWindowWidth: 400 + androidMinimumWindowHeight: 300 + androidFullscreenMode: 1 + androidAutoRotationBehavior: 1 + androidPredictiveBackSupport: 1 + androidApplicationEntry: 2 + defaultIsNativeResolution: 1 + macRetinaSupport: 1 + runInBackground: 0 + muteOtherAudioSources: 0 + Prepare IOS For Recording: 0 + Force IOS Speakers When Recording: 0 + audioSpatialExperience: 0 + deferSystemGesturesMode: 0 + hideHomeButton: 0 + submitAnalytics: 1 + usePlayerLog: 1 + dedicatedServerOptimizations: 1 + bakeCollisionMeshes: 0 + forceSingleInstance: 0 + useFlipModelSwapchain: 1 + resizableWindow: 0 + useMacAppStoreValidation: 0 + macAppStoreCategory: public.app-category.games + gpuSkinning: 0 + meshDeformation: 0 + xboxPIXTextureCapture: 0 + xboxEnableAvatar: 0 + xboxEnableKinect: 0 + xboxEnableKinectAutoTracking: 0 + xboxEnableFitness: 0 + visibleInBackground: 1 + allowFullscreenSwitch: 1 + fullscreenMode: 1 + xboxSpeechDB: 0 + xboxEnableHeadOrientation: 0 + xboxEnableGuest: 0 + xboxEnablePIXSampling: 0 + metalFramebufferOnly: 0 + metalUseMetalDisplayLink: 0 + xboxOneResolution: 0 + xboxOneSResolution: 0 + xboxOneXResolution: 3 + xboxOneMonoLoggingLevel: 0 + xboxOneLoggingLevel: 1 + xboxOneDisableEsram: 0 + xboxOneEnableTypeOptimization: 0 + xboxOnePresentImmediateThreshold: 0 + switchQueueCommandMemory: 1048576 + switchQueueControlMemory: 16384 + switchQueueComputeMemory: 262144 + switchNVNShaderPoolsGranularity: 33554432 + switchNVNDefaultPoolsGranularity: 16777216 + switchNVNOtherPoolsGranularity: 16777216 + switchGpuScratchPoolGranularity: 2097152 + switchAllowGpuScratchShrinking: 0 + switchNVNMaxPublicTextureIDCount: 0 + switchNVNMaxPublicSamplerIDCount: 0 + switchMaxWorkerMultiple: 8 + switchNVNGraphicsFirmwareMemory: 32 + switchGraphicsJobsSyncAfterKick: 1 + vulkanNumSwapchainBuffers: 3 + vulkanEnableSetSRGBWrite: 0 + vulkanEnablePreTransform: 0 + vulkanEnableLateAcquireNextImage: 0 + vulkanEnableCommandBufferRecycling: 1 + loadStoreDebugModeEnabled: 0 + visionOSBundleVersion: 1.0 + tvOSBundleVersion: 1.0 + bundleVersion: 1.0 + preloadedAssets: [] + metroInputSource: 0 + wsaTransparentSwapchain: 0 + m_HolographicPauseOnTrackingLoss: 1 + xboxOneDisableKinectGpuReservation: 1 + xboxOneEnable7thCore: 1 + vrSettings: + enable360StereoCapture: 0 + isWsaHolographicRemotingEnabled: 0 + enableFrameTimingStats: 0 + enableOpenGLProfilerGPURecorders: 1 + allowHDRDisplaySupport: 0 + useHDRDisplay: 0 + hdrBitDepth: 0 + m_ColorGamuts: 00000000 + targetPixelDensity: 30 + resolutionScalingMode: 0 + resetResolutionOnWindowResize: 0 + androidSupportedAspectRatio: 1 + androidMaxAspectRatio: 2.4 + androidMinAspectRatio: 1 + applicationIdentifier: {} + buildNumber: + Standalone: 0 + VisionOS: 0 + iPhone: 0 + tvOS: 0 + overrideDefaultApplicationIdentifier: 0 + AndroidBundleVersionCode: 1 + AndroidMinSdkVersion: 25 + AndroidTargetSdkVersion: 0 + AndroidPreferredInstallLocation: 1 + AndroidPreferredDataLocation: 1 + aotOptions: + stripEngineCode: 1 + iPhoneStrippingLevel: 0 + iPhoneScriptCallOptimization: 0 + ForceInternetPermission: 0 + ForceSDCardPermission: 0 + CreateWallpaper: 0 + androidSplitApplicationBinary: 0 + keepLoadedShadersAlive: 0 + StripUnusedMeshComponents: 0 + strictShaderVariantMatching: 0 + VertexChannelCompressionMask: 4054 + iPhoneSdkVersion: 988 + iOSSimulatorArchitecture: 0 + iOSTargetOSVersionString: 15.0 + tvOSSdkVersion: 0 + tvOSSimulatorArchitecture: 0 + tvOSRequireExtendedGameController: 0 + tvOSTargetOSVersionString: 15.0 + VisionOSSdkVersion: 0 + VisionOSTargetOSVersionString: 1.0 + uIPrerenderedIcon: 0 + uIRequiresPersistentWiFi: 0 + uIRequiresFullScreen: 1 + uIStatusBarHidden: 1 + uIExitOnSuspend: 0 + uIStatusBarStyle: 0 + appleTVSplashScreen: {fileID: 0} + appleTVSplashScreen2x: {fileID: 0} + tvOSSmallIconLayers: [] + tvOSSmallIconLayers2x: [] + tvOSLargeIconLayers: [] + tvOSLargeIconLayers2x: [] + tvOSTopShelfImageLayers: [] + tvOSTopShelfImageLayers2x: [] + tvOSTopShelfImageWideLayers: [] + tvOSTopShelfImageWideLayers2x: [] + iOSLaunchScreenType: 0 + iOSLaunchScreenPortrait: {fileID: 0} + iOSLaunchScreenLandscape: {fileID: 0} + iOSLaunchScreenBackgroundColor: + serializedVersion: 2 + rgba: 0 + iOSLaunchScreenFillPct: 100 + iOSLaunchScreenSize: 100 + iOSLaunchScreeniPadType: 0 + iOSLaunchScreeniPadImage: {fileID: 0} + iOSLaunchScreeniPadBackgroundColor: + serializedVersion: 2 + rgba: 0 + iOSLaunchScreeniPadFillPct: 100 + iOSLaunchScreeniPadSize: 100 + iOSLaunchScreenCustomStoryboardPath: + iOSLaunchScreeniPadCustomStoryboardPath: + iOSDeviceRequirements: [] + iOSURLSchemes: [] + macOSURLSchemes: [] + iOSBackgroundModes: 0 + iOSMetalForceHardShadows: 0 + metalEditorSupport: 1 + metalAPIValidation: 1 + metalCompileShaderBinary: 0 + iOSRenderExtraFrameOnPause: 0 + iosCopyPluginsCodeInsteadOfSymlink: 0 + appleDeveloperTeamID: + iOSManualSigningProvisioningProfileID: + tvOSManualSigningProvisioningProfileID: + VisionOSManualSigningProvisioningProfileID: + iOSManualSigningProvisioningProfileType: 0 + tvOSManualSigningProvisioningProfileType: 0 + VisionOSManualSigningProvisioningProfileType: 0 + appleEnableAutomaticSigning: 0 + iOSRequireARKit: 0 + iOSAutomaticallyDetectAndAddCapabilities: 1 + appleEnableProMotion: 0 + shaderPrecisionModel: 0 + clonedFromGUID: 00000000000000000000000000000000 + templatePackageId: + templateDefaultScene: + useCustomMainManifest: 0 + useCustomLauncherManifest: 0 + useCustomMainGradleTemplate: 0 + useCustomLauncherGradleManifest: 0 + useCustomBaseGradleTemplate: 0 + useCustomGradlePropertiesTemplate: 0 + useCustomGradleSettingsTemplate: 0 + useCustomProguardFile: 0 + AndroidTargetArchitectures: 2 + AndroidAllowedArchitectures: -1 + AndroidSplashScreenScale: 0 + androidSplashScreen: {fileID: 0} + AndroidKeystoreName: + AndroidKeyaliasName: + AndroidEnableArmv9SecurityFeatures: 0 + AndroidEnableArm64MTE: 0 + AndroidBuildApkPerCpuArchitecture: 0 + AndroidTVCompatibility: 0 + AndroidIsGame: 1 + androidAppCategory: 3 + useAndroidAppCategory: 1 + androidAppCategoryOther: + AndroidEnableTango: 0 + androidEnableBanner: 1 + androidUseLowAccuracyLocation: 0 + androidUseCustomKeystore: 0 + m_AndroidBanners: + - width: 320 + height: 180 + banner: {fileID: 0} + androidGamepadSupportLevel: 0 + AndroidMinifyRelease: 0 + AndroidMinifyDebug: 0 + AndroidValidateAppBundleSize: 1 + AndroidAppBundleSizeToValidate: 200 + AndroidReportGooglePlayAppDependencies: 1 + androidSymbolsSizeThreshold: 800 + m_BuildTargetIcons: [] + m_BuildTargetPlatformIcons: + - m_BuildTarget: Android + m_Icons: + - m_Textures: [] + m_Width: 432 + m_Height: 432 + m_Kind: 2 + m_SubKind: + - m_Textures: [] + m_Width: 324 + m_Height: 324 + m_Kind: 2 + m_SubKind: + - m_Textures: [] + m_Width: 216 + m_Height: 216 + m_Kind: 2 + m_SubKind: + - m_Textures: [] + m_Width: 162 + m_Height: 162 + m_Kind: 2 + m_SubKind: + - m_Textures: [] + m_Width: 108 + m_Height: 108 + m_Kind: 2 + m_SubKind: + - m_Textures: [] + m_Width: 81 + m_Height: 81 + m_Kind: 2 + m_SubKind: + - m_Textures: [] + m_Width: 192 + m_Height: 192 + m_Kind: 1 + m_SubKind: + - m_Textures: [] + m_Width: 144 + m_Height: 144 + m_Kind: 1 + m_SubKind: + - m_Textures: [] + m_Width: 96 + m_Height: 96 + m_Kind: 1 + m_SubKind: + - m_Textures: [] + m_Width: 72 + m_Height: 72 + m_Kind: 1 + m_SubKind: + - m_Textures: [] + m_Width: 48 + m_Height: 48 + m_Kind: 1 + m_SubKind: + - m_Textures: [] + m_Width: 36 + m_Height: 36 + m_Kind: 1 + m_SubKind: + - m_Textures: [] + m_Width: 192 + m_Height: 192 + m_Kind: 0 + m_SubKind: + - m_Textures: [] + m_Width: 144 + m_Height: 144 + m_Kind: 0 + m_SubKind: + - m_Textures: [] + m_Width: 96 + m_Height: 96 + m_Kind: 0 + m_SubKind: + - m_Textures: [] + m_Width: 72 + m_Height: 72 + m_Kind: 0 + m_SubKind: + - m_Textures: [] + m_Width: 48 + m_Height: 48 + m_Kind: 0 + m_SubKind: + - m_Textures: [] + m_Width: 36 + m_Height: 36 + m_Kind: 0 + m_SubKind: + m_BuildTargetBatching: [] + m_BuildTargetShaderSettings: [] + m_BuildTargetGraphicsJobs: [] + m_BuildTargetGraphicsJobMode: [] + m_BuildTargetGraphicsAPIs: [] + m_BuildTargetVRSettings: [] + m_DefaultShaderChunkSizeInMB: 16 + m_DefaultShaderChunkCount: 0 + openGLRequireES31: 0 + openGLRequireES31AEP: 0 + openGLRequireES32: 0 + m_TemplateCustomTags: {} + mobileMTRendering: + Android: 1 + VisionOS: 1 + iPhone: 1 + tvOS: 1 + m_BuildTargetGroupLightmapEncodingQuality: [] + m_BuildTargetGroupHDRCubemapEncodingQuality: [] + m_BuildTargetGroupLightmapSettings: [] + m_BuildTargetGroupLoadStoreDebugModeSettings: [] + m_BuildTargetNormalMapEncoding: [] + m_BuildTargetDefaultTextureCompressionFormat: [] + playModeTestRunnerEnabled: 0 + runPlayModeTestAsEditModeTest: 0 + actionOnDotNetUnhandledException: 1 + editorGfxJobOverride: 1 + enableInternalProfiler: 0 + logObjCUncaughtExceptions: 1 + enableCrashReportAPI: 0 + cameraUsageDescription: + locationUsageDescription: + microphoneUsageDescription: + bluetoothUsageDescription: + macOSTargetOSVersion: 12.0 + switchNMETAOverride: + switchNetLibKey: + switchSocketMemoryPoolSize: 6144 + switchSocketAllocatorPoolSize: 128 + switchSocketConcurrencyLimit: 14 + switchScreenResolutionBehavior: 2 + switchUseCPUProfiler: 0 + switchEnableFileSystemTrace: 0 + switchLTOSetting: 0 + switchApplicationID: 0x01004b9000490000 + switchNSODependencies: + switchCompilerFlags: + switchTitleNames_0: + switchTitleNames_1: + switchTitleNames_2: + switchTitleNames_3: + switchTitleNames_4: + switchTitleNames_5: + switchTitleNames_6: + switchTitleNames_7: + switchTitleNames_8: + switchTitleNames_9: + switchTitleNames_10: + switchTitleNames_11: + switchTitleNames_12: + switchTitleNames_13: + switchTitleNames_14: + switchTitleNames_15: + switchPublisherNames_0: + switchPublisherNames_1: + switchPublisherNames_2: + switchPublisherNames_3: + switchPublisherNames_4: + switchPublisherNames_5: + switchPublisherNames_6: + switchPublisherNames_7: + switchPublisherNames_8: + switchPublisherNames_9: + switchPublisherNames_10: + switchPublisherNames_11: + switchPublisherNames_12: + switchPublisherNames_13: + switchPublisherNames_14: + switchPublisherNames_15: + switchIcons_0: {fileID: 0} + switchIcons_1: {fileID: 0} + switchIcons_2: {fileID: 0} + switchIcons_3: {fileID: 0} + switchIcons_4: {fileID: 0} + switchIcons_5: {fileID: 0} + switchIcons_6: {fileID: 0} + switchIcons_7: {fileID: 0} + switchIcons_8: {fileID: 0} + switchIcons_9: {fileID: 0} + switchIcons_10: {fileID: 0} + switchIcons_11: {fileID: 0} + switchIcons_12: {fileID: 0} + switchIcons_13: {fileID: 0} + switchIcons_14: {fileID: 0} + switchIcons_15: {fileID: 0} + switchSmallIcons_0: {fileID: 0} + switchSmallIcons_1: {fileID: 0} + switchSmallIcons_2: {fileID: 0} + switchSmallIcons_3: {fileID: 0} + switchSmallIcons_4: {fileID: 0} + switchSmallIcons_5: {fileID: 0} + switchSmallIcons_6: {fileID: 0} + switchSmallIcons_7: {fileID: 0} + switchSmallIcons_8: {fileID: 0} + switchSmallIcons_9: {fileID: 0} + switchSmallIcons_10: {fileID: 0} + switchSmallIcons_11: {fileID: 0} + switchSmallIcons_12: {fileID: 0} + switchSmallIcons_13: {fileID: 0} + switchSmallIcons_14: {fileID: 0} + switchSmallIcons_15: {fileID: 0} + switchManualHTML: + switchAccessibleURLs: + switchLegalInformation: + switchMainThreadStackSize: 1048576 + switchPresenceGroupId: + switchLogoHandling: 0 + switchReleaseVersion: 0 + switchDisplayVersion: 1.0.0 + switchStartupUserAccount: 0 + switchSupportedLanguagesMask: 0 + switchLogoType: 0 + switchApplicationErrorCodeCategory: + switchUserAccountSaveDataSize: 0 + switchUserAccountSaveDataJournalSize: 0 + switchApplicationAttribute: 0 + switchCardSpecSize: -1 + switchCardSpecClock: -1 + switchRatingsMask: 0 + switchRatingsInt_0: 0 + switchRatingsInt_1: 0 + switchRatingsInt_2: 0 + switchRatingsInt_3: 0 + switchRatingsInt_4: 0 + switchRatingsInt_5: 0 + switchRatingsInt_6: 0 + switchRatingsInt_7: 0 + switchRatingsInt_8: 0 + switchRatingsInt_9: 0 + switchRatingsInt_10: 0 + switchRatingsInt_11: 0 + switchRatingsInt_12: 0 + switchLocalCommunicationIds_0: + switchLocalCommunicationIds_1: + switchLocalCommunicationIds_2: + switchLocalCommunicationIds_3: + switchLocalCommunicationIds_4: + switchLocalCommunicationIds_5: + switchLocalCommunicationIds_6: + switchLocalCommunicationIds_7: + switchParentalControl: 0 + switchAllowsScreenshot: 1 + switchAllowsVideoCapturing: 1 + switchAllowsRuntimeAddOnContentInstall: 0 + switchDataLossConfirmation: 0 + switchUserAccountLockEnabled: 0 + switchSystemResourceMemory: 16777216 + switchSupportedNpadStyles: 22 + switchNativeFsCacheSize: 32 + switchIsHoldTypeHorizontal: 1 + switchSupportedNpadCount: 8 + switchEnableTouchScreen: 1 + switchSocketConfigEnabled: 0 + switchTcpInitialSendBufferSize: 32 + switchTcpInitialReceiveBufferSize: 64 + switchTcpAutoSendBufferSizeMax: 256 + switchTcpAutoReceiveBufferSizeMax: 256 + switchUdpSendBufferSize: 9 + switchUdpReceiveBufferSize: 42 + switchSocketBufferEfficiency: 4 + switchSocketInitializeEnabled: 1 + switchNetworkInterfaceManagerInitializeEnabled: 1 + switchDisableHTCSPlayerConnection: 0 + switchUseNewStyleFilepaths: 1 + switchUseLegacyFmodPriorities: 0 + switchUseMicroSleepForYield: 1 + switchEnableRamDiskSupport: 0 + switchMicroSleepForYieldTime: 25 + switchRamDiskSpaceSize: 12 + switchUpgradedPlayerSettingsToNMETA: 0 + ps4NPAgeRating: 12 + ps4NPTitleSecret: + ps4NPTrophyPackPath: + ps4ParentalLevel: 11 + ps4ContentID: ED1633-NPXX51362_00-0000000000000000 + ps4Category: 0 + ps4MasterVersion: 01.00 + ps4AppVersion: 01.00 + ps4AppType: 0 + ps4ParamSfxPath: + ps4VideoOutPixelFormat: 0 + ps4VideoOutInitialWidth: 1920 + ps4VideoOutBaseModeInitialWidth: 1920 + ps4VideoOutReprojectionRate: 60 + ps4PronunciationXMLPath: + ps4PronunciationSIGPath: + ps4BackgroundImagePath: + ps4StartupImagePath: + ps4StartupImagesFolder: + ps4IconImagesFolder: + ps4SaveDataImagePath: + ps4SdkOverride: + ps4BGMPath: + ps4ShareFilePath: + ps4ShareOverlayImagePath: + ps4PrivacyGuardImagePath: + ps4ExtraSceSysFile: + ps4NPtitleDatPath: + ps4RemotePlayKeyAssignment: -1 + ps4RemotePlayKeyMappingDir: + ps4PlayTogetherPlayerCount: 0 + ps4EnterButtonAssignment: 2 + ps4ApplicationParam1: 0 + ps4ApplicationParam2: 0 + ps4ApplicationParam3: 0 + ps4ApplicationParam4: 0 + ps4DownloadDataSize: 0 + ps4GarlicHeapSize: 2048 + ps4ProGarlicHeapSize: 2560 + playerPrefsMaxSize: 32768 + ps4Passcode: frAQBc8Wsa1xVPfvJcrgRYwTiizs2trQ + ps4pnSessions: 1 + ps4pnPresence: 1 + ps4pnFriends: 1 + ps4pnGameCustomData: 1 + playerPrefsSupport: 0 + enableApplicationExit: 0 + resetTempFolder: 1 + restrictedAudioUsageRights: 0 + ps4UseResolutionFallback: 0 + ps4ReprojectionSupport: 0 + ps4UseAudio3dBackend: 0 + ps4UseLowGarlicFragmentationMode: 1 + ps4SocialScreenEnabled: 0 + ps4ScriptOptimizationLevel: 2 + ps4Audio3dVirtualSpeakerCount: 14 + ps4attribCpuUsage: 0 + ps4PatchPkgPath: + ps4PatchLatestPkgPath: + ps4PatchChangeinfoPath: + ps4PatchDayOne: 0 + ps4attribUserManagement: 0 + ps4attribMoveSupport: 0 + ps4attrib3DSupport: 0 + ps4attribShareSupport: 0 + ps4attribExclusiveVR: 0 + ps4disableAutoHideSplash: 0 + ps4videoRecordingFeaturesUsed: 0 + ps4contentSearchFeaturesUsed: 0 + ps4CompatibilityPS5: 0 + ps4AllowPS5Detection: 0 + ps4GPU800MHz: 1 + ps4attribEyeToEyeDistanceSettingVR: 0 + ps4IncludedModules: [] + ps4attribVROutputEnabled: 0 + monoEnv: + splashScreenBackgroundSourceLandscape: {fileID: 0} + splashScreenBackgroundSourcePortrait: {fileID: 0} + blurSplashScreenBackground: 1 + spritePackerPolicy: + webGLMemorySize: 32 + webGLExceptionSupport: 1 + webGLNameFilesAsHashes: 0 + webGLShowDiagnostics: 0 + webGLDataCaching: 1 + webGLDebugSymbols: 0 + webGLEmscriptenArgs: + webGLModulesDirectory: + webGLTemplate: APPLICATION:Default + webGLAnalyzeBuildSize: 0 + webGLUseEmbeddedResources: 0 + webGLCompressionFormat: 1 + webGLWasmArithmeticExceptions: 0 + webGLLinkerTarget: 1 + webGLThreadsSupport: 0 + webGLDecompressionFallback: 0 + webGLInitialMemorySize: 32 + webGLMaximumMemorySize: 2048 + webGLMemoryGrowthMode: 2 + webGLMemoryLinearGrowthStep: 16 + webGLMemoryGeometricGrowthStep: 0.2 + webGLMemoryGeometricGrowthCap: 96 + webGLPowerPreference: 2 + webGLWebAssemblyTable: 0 + webGLWebAssemblyBigInt: 0 + webGLCloseOnQuit: 0 + webWasm2023: 0 + webEnableSubmoduleStrippingCompatibility: 0 + scriptingDefineSymbols: {} + additionalCompilerArguments: {} + platformArchitecture: {} + scriptingBackend: {} + il2cppCompilerConfiguration: {} + il2cppCodeGeneration: {} + il2cppStacktraceInformation: {} + managedStrippingLevel: {} + incrementalIl2cppBuild: {} + suppressCommonWarnings: 1 + allowUnsafeCode: 0 + useDeterministicCompilation: 1 + additionalIl2CppArgs: + scriptingRuntimeVersion: 1 + gcIncremental: 1 + gcWBarrierValidation: 0 + apiCompatibilityLevelPerPlatform: {} + editorAssembliesCompatibilityLevel: 1 + m_RenderingPath: 1 + m_MobileRenderingPath: 1 + metroPackageName: MagicExamHall + metroPackageVersion: + metroCertificatePath: + metroCertificatePassword: + metroCertificateSubject: + metroCertificateIssuer: + metroCertificateNotAfter: 0000000000000000 + metroApplicationDescription: MagicExamHall + wsaImages: {} + metroTileShortName: + metroTileShowName: 0 + metroMediumTileShowName: 0 + metroLargeTileShowName: 0 + metroWideTileShowName: 0 + metroSupportStreamingInstall: 0 + metroLastRequiredScene: 0 + metroDefaultTileSize: 1 + metroTileForegroundText: 2 + metroTileBackgroundColor: {r: 0.13333334, g: 0.17254902, b: 0.21568628, a: 0} + metroSplashScreenBackgroundColor: {r: 0.12941177, g: 0.17254902, b: 0.21568628, a: 1} + metroSplashScreenUseBackgroundColor: 0 + syncCapabilities: 0 + platformCapabilities: {} + metroTargetDeviceFamilies: {} + metroFTAName: + metroFTAFileTypes: [] + metroProtocolName: + vcxProjDefaultLanguage: + XboxOneProductId: + XboxOneUpdateKey: + XboxOneSandboxId: + XboxOneContentId: + XboxOneTitleId: + XboxOneSCId: + XboxOneGameOsOverridePath: + XboxOnePackagingOverridePath: + XboxOneAppManifestOverridePath: + XboxOneVersion: 1.0.0.0 + XboxOnePackageEncryption: 0 + XboxOnePackageUpdateGranularity: 2 + XboxOneDescription: + XboxOneLanguage: + - enus + XboxOneCapability: [] + XboxOneGameRating: {} + XboxOneIsContentPackage: 0 + XboxOneEnhancedXboxCompatibilityMode: 0 + XboxOneEnableGPUVariability: 1 + XboxOneSockets: {} + XboxOneSplashScreen: {fileID: 0} + XboxOneAllowedProductIds: [] + XboxOnePersistentLocalStorageSize: 0 + XboxOneXTitleMemory: 8 + XboxOneOverrideIdentityName: + XboxOneOverrideIdentityPublisher: + vrEditorSettings: {} + cloudServicesEnabled: {} + luminIcon: + m_Name: + m_ModelFolderPath: + m_PortalFolderPath: + luminCert: + m_CertPath: + m_SignPackage: 1 + luminIsChannelApp: 0 + luminVersion: + m_VersionCode: 1 + m_VersionName: + hmiPlayerDataPath: + hmiForceSRGBBlit: 0 + embeddedLinuxEnableGamepadInput: 0 + hmiCpuConfiguration: + hmiLogStartupTiming: 0 + qnxGraphicConfPath: + apiCompatibilityLevel: 6 + captureStartupLogs: {} + activeInputHandler: 0 + windowsGamepadBackendHint: 0 + cloudProjectId: + framebufferDepthMemorylessMode: 0 + qualitySettingsNames: [] + projectName: + organizationId: + cloudEnabled: 0 + legacyClampBlendShapeWeights: 0 + hmiLoadingImage: {fileID: 0} + platformRequiresReadableAssets: 0 + virtualTexturingSupportEnabled: 0 + insecureHttpOption: 0 + androidVulkanDenyFilterList: [] + androidVulkanAllowFilterList: [] + androidVulkanDeviceFilterListAsset: {fileID: 0} + d3d12DeviceFilterListAsset: {fileID: 0} + allowedHttpConnections: 3 diff --git a/unity/MagicExamHall/ProjectSettings/ProjectVersion.txt b/unity/MagicExamHall/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..88b74dd --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 6000.3.14f1 +m_EditorVersionWithRevision: 6000.3.14f1 (d68c3f99a318) diff --git a/unity/MagicExamHall/ProjectSettings/QualitySettings.asset b/unity/MagicExamHall/ProjectSettings/QualitySettings.asset new file mode 100644 index 0000000..64f8aba --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/QualitySettings.asset @@ -0,0 +1,347 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!47 &1 +QualitySettings: + m_ObjectHideFlags: 0 + serializedVersion: 5 + m_CurrentQuality: 5 + m_QualitySettings: + - serializedVersion: 5 + name: Very Low + pixelLightCount: 0 + shadows: 0 + shadowResolution: 0 + shadowProjection: 1 + shadowCascades: 1 + shadowDistance: 15 + shadowNearPlaneOffset: 3 + shadowCascade2Split: 0.33333334 + shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} + shadowmaskMode: 0 + skinWeights: 1 + globalTextureMipmapLimit: 1 + textureMipmapLimitSettings: [] + anisotropicTextures: 0 + antiAliasing: 0 + softParticles: 0 + softVegetation: 0 + realtimeReflectionProbes: 0 + billboardsFaceCameraPosition: 0 + useLegacyDetailDistribution: 0 + adaptiveVsync: 0 + vSyncCount: 0 + realtimeGICPUUsage: 25 + adaptiveVsyncExtraA: 0 + adaptiveVsyncExtraB: 0 + lodBias: 0.3 + meshLodThreshold: 1 + maximumLODLevel: 0 + enableLODCrossFade: 1 + streamingMipmapsActive: 0 + streamingMipmapsAddAllCameras: 1 + streamingMipmapsMemoryBudget: 512 + streamingMipmapsRenderersPerFrame: 512 + streamingMipmapsMaxLevelReduction: 2 + streamingMipmapsMaxFileIORequests: 1024 + particleRaycastBudget: 4 + asyncUploadTimeSlice: 2 + asyncUploadBufferSize: 16 + asyncUploadPersistentBuffer: 1 + resolutionScalingFixedDPIFactor: 1 + customRenderPipeline: {fileID: 0} + terrainQualityOverrides: 0 + terrainPixelError: 1 + terrainDetailDensityScale: 1 + terrainBasemapDistance: 1000 + terrainDetailDistance: 80 + terrainTreeDistance: 5000 + terrainBillboardStart: 50 + terrainFadeLength: 5 + terrainMaxTrees: 50 + excludedTargetPlatforms: [] + - serializedVersion: 5 + name: Low + pixelLightCount: 0 + shadows: 0 + shadowResolution: 0 + shadowProjection: 1 + shadowCascades: 1 + shadowDistance: 20 + shadowNearPlaneOffset: 3 + shadowCascade2Split: 0.33333334 + shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} + shadowmaskMode: 0 + skinWeights: 2 + globalTextureMipmapLimit: 0 + textureMipmapLimitSettings: [] + anisotropicTextures: 0 + antiAliasing: 0 + softParticles: 0 + softVegetation: 0 + realtimeReflectionProbes: 0 + billboardsFaceCameraPosition: 0 + useLegacyDetailDistribution: 0 + adaptiveVsync: 0 + vSyncCount: 0 + realtimeGICPUUsage: 25 + adaptiveVsyncExtraA: 0 + adaptiveVsyncExtraB: 0 + lodBias: 0.4 + meshLodThreshold: 1 + maximumLODLevel: 0 + enableLODCrossFade: 1 + streamingMipmapsActive: 0 + streamingMipmapsAddAllCameras: 1 + streamingMipmapsMemoryBudget: 512 + streamingMipmapsRenderersPerFrame: 512 + streamingMipmapsMaxLevelReduction: 2 + streamingMipmapsMaxFileIORequests: 1024 + particleRaycastBudget: 16 + asyncUploadTimeSlice: 2 + asyncUploadBufferSize: 16 + asyncUploadPersistentBuffer: 1 + resolutionScalingFixedDPIFactor: 1 + customRenderPipeline: {fileID: 0} + terrainQualityOverrides: 0 + terrainPixelError: 1 + terrainDetailDensityScale: 1 + terrainBasemapDistance: 1000 + terrainDetailDistance: 80 + terrainTreeDistance: 5000 + terrainBillboardStart: 50 + terrainFadeLength: 5 + terrainMaxTrees: 50 + excludedTargetPlatforms: [] + - serializedVersion: 5 + name: Medium + pixelLightCount: 1 + shadows: 1 + shadowResolution: 0 + shadowProjection: 1 + shadowCascades: 1 + shadowDistance: 20 + shadowNearPlaneOffset: 3 + shadowCascade2Split: 0.33333334 + shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} + shadowmaskMode: 0 + skinWeights: 2 + globalTextureMipmapLimit: 0 + textureMipmapLimitSettings: [] + anisotropicTextures: 1 + antiAliasing: 0 + softParticles: 0 + softVegetation: 0 + realtimeReflectionProbes: 0 + billboardsFaceCameraPosition: 0 + useLegacyDetailDistribution: 0 + adaptiveVsync: 0 + vSyncCount: 1 + realtimeGICPUUsage: 25 + adaptiveVsyncExtraA: 0 + adaptiveVsyncExtraB: 0 + lodBias: 0.7 + meshLodThreshold: 1 + maximumLODLevel: 0 + enableLODCrossFade: 1 + streamingMipmapsActive: 0 + streamingMipmapsAddAllCameras: 1 + streamingMipmapsMemoryBudget: 512 + streamingMipmapsRenderersPerFrame: 512 + streamingMipmapsMaxLevelReduction: 2 + streamingMipmapsMaxFileIORequests: 1024 + particleRaycastBudget: 64 + asyncUploadTimeSlice: 2 + asyncUploadBufferSize: 16 + asyncUploadPersistentBuffer: 1 + resolutionScalingFixedDPIFactor: 1 + customRenderPipeline: {fileID: 0} + terrainQualityOverrides: 0 + terrainPixelError: 1 + terrainDetailDensityScale: 1 + terrainBasemapDistance: 1000 + terrainDetailDistance: 80 + terrainTreeDistance: 5000 + terrainBillboardStart: 50 + terrainFadeLength: 5 + terrainMaxTrees: 50 + excludedTargetPlatforms: [] + - serializedVersion: 5 + name: High + pixelLightCount: 2 + shadows: 2 + shadowResolution: 1 + shadowProjection: 1 + shadowCascades: 2 + shadowDistance: 40 + shadowNearPlaneOffset: 3 + shadowCascade2Split: 0.33333334 + shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} + shadowmaskMode: 1 + skinWeights: 2 + globalTextureMipmapLimit: 0 + textureMipmapLimitSettings: [] + anisotropicTextures: 1 + antiAliasing: 0 + softParticles: 0 + softVegetation: 1 + realtimeReflectionProbes: 1 + billboardsFaceCameraPosition: 1 + useLegacyDetailDistribution: 0 + adaptiveVsync: 0 + vSyncCount: 1 + realtimeGICPUUsage: 50 + adaptiveVsyncExtraA: 0 + adaptiveVsyncExtraB: 0 + lodBias: 1 + meshLodThreshold: 1 + maximumLODLevel: 0 + enableLODCrossFade: 1 + streamingMipmapsActive: 0 + streamingMipmapsAddAllCameras: 1 + streamingMipmapsMemoryBudget: 512 + streamingMipmapsRenderersPerFrame: 512 + streamingMipmapsMaxLevelReduction: 2 + streamingMipmapsMaxFileIORequests: 1024 + particleRaycastBudget: 256 + asyncUploadTimeSlice: 2 + asyncUploadBufferSize: 16 + asyncUploadPersistentBuffer: 1 + resolutionScalingFixedDPIFactor: 1 + customRenderPipeline: {fileID: 0} + terrainQualityOverrides: 0 + terrainPixelError: 1 + terrainDetailDensityScale: 1 + terrainBasemapDistance: 1000 + terrainDetailDistance: 80 + terrainTreeDistance: 5000 + terrainBillboardStart: 50 + terrainFadeLength: 5 + terrainMaxTrees: 50 + excludedTargetPlatforms: [] + - serializedVersion: 5 + name: Very High + pixelLightCount: 3 + shadows: 2 + shadowResolution: 2 + shadowProjection: 1 + shadowCascades: 2 + shadowDistance: 70 + shadowNearPlaneOffset: 3 + shadowCascade2Split: 0.33333334 + shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} + shadowmaskMode: 1 + skinWeights: 4 + globalTextureMipmapLimit: 0 + textureMipmapLimitSettings: [] + anisotropicTextures: 2 + antiAliasing: 2 + softParticles: 1 + softVegetation: 1 + realtimeReflectionProbes: 1 + billboardsFaceCameraPosition: 1 + useLegacyDetailDistribution: 0 + adaptiveVsync: 0 + vSyncCount: 1 + realtimeGICPUUsage: 50 + adaptiveVsyncExtraA: 0 + adaptiveVsyncExtraB: 0 + lodBias: 1.5 + meshLodThreshold: 1 + maximumLODLevel: 0 + enableLODCrossFade: 1 + streamingMipmapsActive: 0 + streamingMipmapsAddAllCameras: 1 + streamingMipmapsMemoryBudget: 512 + streamingMipmapsRenderersPerFrame: 512 + streamingMipmapsMaxLevelReduction: 2 + streamingMipmapsMaxFileIORequests: 1024 + particleRaycastBudget: 1024 + asyncUploadTimeSlice: 2 + asyncUploadBufferSize: 16 + asyncUploadPersistentBuffer: 1 + resolutionScalingFixedDPIFactor: 1 + customRenderPipeline: {fileID: 0} + terrainQualityOverrides: 0 + terrainPixelError: 1 + terrainDetailDensityScale: 1 + terrainBasemapDistance: 1000 + terrainDetailDistance: 80 + terrainTreeDistance: 5000 + terrainBillboardStart: 50 + terrainFadeLength: 5 + terrainMaxTrees: 50 + excludedTargetPlatforms: [] + - serializedVersion: 5 + name: Ultra + pixelLightCount: 4 + shadows: 2 + shadowResolution: 2 + shadowProjection: 1 + shadowCascades: 4 + shadowDistance: 150 + shadowNearPlaneOffset: 3 + shadowCascade2Split: 0.33333334 + shadowCascade4Split: {x: 0.06666667, y: 0.2, z: 0.46666667} + shadowmaskMode: 1 + skinWeights: 255 + globalTextureMipmapLimit: 0 + textureMipmapLimitSettings: [] + anisotropicTextures: 2 + antiAliasing: 2 + softParticles: 1 + softVegetation: 1 + realtimeReflectionProbes: 1 + billboardsFaceCameraPosition: 1 + useLegacyDetailDistribution: 0 + adaptiveVsync: 0 + vSyncCount: 1 + realtimeGICPUUsage: 100 + adaptiveVsyncExtraA: 0 + adaptiveVsyncExtraB: 0 + lodBias: 2 + meshLodThreshold: 1 + maximumLODLevel: 0 + enableLODCrossFade: 1 + streamingMipmapsActive: 0 + streamingMipmapsAddAllCameras: 1 + streamingMipmapsMemoryBudget: 512 + streamingMipmapsRenderersPerFrame: 512 + streamingMipmapsMaxLevelReduction: 2 + streamingMipmapsMaxFileIORequests: 1024 + particleRaycastBudget: 4096 + asyncUploadTimeSlice: 2 + asyncUploadBufferSize: 16 + asyncUploadPersistentBuffer: 1 + resolutionScalingFixedDPIFactor: 1 + customRenderPipeline: {fileID: 0} + terrainQualityOverrides: 0 + terrainPixelError: 1 + terrainDetailDensityScale: 1 + terrainBasemapDistance: 1000 + terrainDetailDistance: 80 + terrainTreeDistance: 5000 + terrainBillboardStart: 50 + terrainFadeLength: 5 + terrainMaxTrees: 50 + excludedTargetPlatforms: [] + m_TextureMipmapLimitGroupNames: [] + m_PerPlatformDefaultQuality: + Android: 2 + EmbeddedLinux: 5 + GameCoreScarlett: 5 + GameCoreXboxOne: 5 + Kepler: 5 + LinuxHeadlessSimulation: 5 + Nintendo Switch: 5 + Nintendo Switch 2: 5 + PS4: 5 + PS5: 5 + QNX: 5 + Server: 5 + Standalone: 5 + VisionOS: 5 + WebGL: 3 + Windows Store Apps: 5 + XboxOne: 5 + iPhone: 2 + tvOS: 2 diff --git a/unity/MagicExamHall/ProjectSettings/SceneTemplateSettings.json b/unity/MagicExamHall/ProjectSettings/SceneTemplateSettings.json new file mode 100644 index 0000000..ede5887 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/SceneTemplateSettings.json @@ -0,0 +1,121 @@ +{ + "templatePinStates": [], + "dependencyTypeInfos": [ + { + "userAdded": false, + "type": "UnityEngine.AnimationClip", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.Animations.AnimatorController", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.AnimatorOverrideController", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.Audio.AudioMixerController", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.ComputeShader", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.Cubemap", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.GameObject", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.LightingDataAsset", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.LightingSettings", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Material", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.MonoScript", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.PhysicsMaterial", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.PhysicsMaterial2D", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Rendering.PostProcessing.PostProcessProfile", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Rendering.PostProcessing.PostProcessResources", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Rendering.VolumeProfile", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEditor.SceneAsset", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.Shader", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.ShaderVariantCollection", + "defaultInstantiationMode": 1 + }, + { + "userAdded": false, + "type": "UnityEngine.Texture", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Texture2D", + "defaultInstantiationMode": 0 + }, + { + "userAdded": false, + "type": "UnityEngine.Timeline.TimelineAsset", + "defaultInstantiationMode": 0 + } + ], + "defaultDependencyTypeInfo": { + "userAdded": false, + "type": "", + "defaultInstantiationMode": 1 + }, + "newSceneOverride": 0 +} \ No newline at end of file diff --git a/unity/MagicExamHall/ProjectSettings/TagManager.asset b/unity/MagicExamHall/ProjectSettings/TagManager.asset new file mode 100644 index 0000000..eb5d9ae --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/TagManager.asset @@ -0,0 +1,45 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!78 &1 +TagManager: + serializedVersion: 3 + tags: [] + layers: + - Default + - TransparentFX + - Ignore Raycast + - + - Water + - UI + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + m_SortingLayers: + - name: Default + uniqueID: 0 + locked: 0 + m_RenderingLayers: + - Default diff --git a/unity/MagicExamHall/ProjectSettings/TimeManager.asset b/unity/MagicExamHall/ProjectSettings/TimeManager.asset new file mode 100644 index 0000000..2e23a1f --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/TimeManager.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!5 &1 +TimeManager: + m_ObjectHideFlags: 0 + serializedVersion: 2 + Fixed Timestep: + m_Count: 2822399 + m_Rate: + m_Denominator: 1 + m_Numerator: 141120000 + Maximum Allowed Timestep: 0.33333334 + m_TimeScale: 1 + Maximum Particle Timestep: 0.03 diff --git a/unity/MagicExamHall/ProjectSettings/UnityConnectSettings.asset b/unity/MagicExamHall/ProjectSettings/UnityConnectSettings.asset new file mode 100644 index 0000000..5ef5698 --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/UnityConnectSettings.asset @@ -0,0 +1,40 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!310 &1 +UnityConnectSettings: + m_ObjectHideFlags: 0 + serializedVersion: 1 + m_Enabled: 0 + m_TestMode: 0 + m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events + m_EventUrl: https://cdp.cloud.unity3d.com/v1/events + m_ConfigUrl: https://config.uca.cloud.unity3d.com + m_DashboardUrl: https://dashboard.unity3d.com + m_TestInitMode: 0 + InsightsSettings: + m_EngineDiagnosticsEnabled: 0 + m_Enabled: 0 + CrashReportingSettings: + serializedVersion: 2 + m_EventUrl: https://perf-events.cloud.unity3d.com + m_EnableCloudDiagnosticsReporting: 0 + m_LogBufferSize: 10 + m_CaptureEditorExceptions: 1 + UnityPurchasingSettings: + m_Enabled: 0 + m_TestMode: 0 + UnityAnalyticsSettings: + m_Enabled: 0 + m_TestMode: 0 + m_InitializeOnStartup: 1 + m_PackageRequiringCoreStatsPresent: 0 + UnityAdsSettings: + m_Enabled: 0 + m_InitializeOnStartup: 1 + m_TestMode: 0 + m_IosGameId: + m_AndroidGameId: + m_GameIds: {} + m_GameId: + PerformanceReportingSettings: + m_Enabled: 0 diff --git a/unity/MagicExamHall/ProjectSettings/VFXManager.asset b/unity/MagicExamHall/ProjectSettings/VFXManager.asset new file mode 100644 index 0000000..56783bb --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/VFXManager.asset @@ -0,0 +1,20 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!937362698 &1 +VFXManager: + m_ObjectHideFlags: 0 + m_IndirectShader: {fileID: 0} + m_CopyBufferShader: {fileID: 0} + m_PrefixSumShader: {fileID: 0} + m_SortShader: {fileID: 0} + m_StripUpdateShader: {fileID: 0} + m_EmptyShader: {fileID: 0} + m_RenderPipeSettingsPath: + m_FixedTimeStep: 0.016666668 + m_MaxDeltaTime: 0.05 + m_MaxScrubTime: 30 + m_MaxCapacity: 100000000 + m_CompiledVersion: 0 + m_RuntimeVersion: 0 + m_RuntimeResources: {fileID: 0} + m_BatchEmptyLifetime: 300 diff --git a/unity/MagicExamHall/ProjectSettings/VersionControlSettings.asset b/unity/MagicExamHall/ProjectSettings/VersionControlSettings.asset new file mode 100644 index 0000000..979fd8e --- /dev/null +++ b/unity/MagicExamHall/ProjectSettings/VersionControlSettings.asset @@ -0,0 +1,7 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!890905787 &1 +VersionControlSettings: + m_ObjectHideFlags: 0 + m_Mode: Visible Meta Files + m_TrackPackagesOutsideProject: 0 diff --git a/unity/MagicExamHall/README.md b/unity/MagicExamHall/README.md new file mode 100644 index 0000000..02265dd --- /dev/null +++ b/unity/MagicExamHall/README.md @@ -0,0 +1,74 @@ +# Magic Exam Hall + +Unity 6.3 LTS 2D top-down JRPG/indie pixel game implementation for the Magic Recognizer term project. + +## How to Run + +1. Open `unity/MagicExamHall` in Unity 6.3 LTS. +2. Open `Assets/Scenes/MagicExamHall.unity`. +3. Press Play. + +The scene contains a top-down exam tower room, player, world-space casting input, HUD, magic note toast, generated pixel sprites, and a five-floor progression loop. If the scene needs to be regenerated, use Unity's menu: + +```text +Magic Exam Hall/Rebuild Demo Scene +``` + +## Controls + +- 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 + +There is no default drawing panel, cast button, or station modal in the playable flow. + +## Game Flow + +The first implementation is thin but complete: start on floor 1, climb through all five floors, and finish with an ending report. + +1. Floor 1, Departure: experiment with the five base families. +2. Floor 2, Reaction: discover all six overlay operators. +3. Floor 3, Flow: connect broken bridge states with base + overlay combinations. +4. Floor 4, Fracture: avoid hazards and stabilize unstable rune states. +5. Floor 5, Constellation Heart: restore the final admission circle. + +## Spell Runtime + +- 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. +- Failed recognition creates a weak ripple and a short magic-note hint instead of health loss or death. + +## Logs + +Runtime logs are saved under: + +```text +Application.persistentDataPath/MagicExamHallLogs// +``` + +Each session writes: + +- `attempts.jsonl` +- `attempts.csv` +- `survey.jsonl` +- `survey.csv` + +Attempt logs include spell phase, base family, overlay stack, seal id, floor id, target object, world effect, world position, recognition quality, and assist state. + +## Verification + +EditMode tests cover base recognition, overlay recognition, `martial_axis` dependency, quality vectors, hint escalation, and log schema. PlayMode smoke tests load the world-casting scene, create a base seal, attach overlays, advance floors, reset on a hazard, and show the ending report. + +```powershell +& 'C:\Program Files\Unity\Hub\Editor\6000.3.14f1\Editor\Unity.com' -batchmode -quit -projectPath 'C:\Users\silve\source\repos\magic\unity\MagicExamHall' -executeMethod MagicExamHall.Editor.MagicExamHallSceneBuilder.BuildAll +& 'C:\Program Files\Unity\Hub\Editor\6000.3.14f1\Editor\Unity.com' -batchmode -projectPath 'C:\Users\silve\source\repos\magic\unity\MagicExamHall' -runTests -testPlatform editmode -testResults 'C:\Users\silve\source\repos\magic\unity\MagicExamHall\TestResults.xml' +& 'C:\Program Files\Unity\Hub\Editor\6000.3.14f1\Editor\Unity.com' -batchmode -projectPath 'C:\Users\silve\source\repos\magic\unity\MagicExamHall' -runTests -testPlatform playmode -testResults 'C:\Users\silve\source\repos\magic\unity\MagicExamHall\PlayModeTestResults.xml' +``` + +## Troubleshooting + +If batchmode exits before compile/test output and the log contains `No valid Unity Editor license found`, activate the Unity Editor license first and rerun the command. + +Messages like `abort_threads: Failed aborting id ... mono_thread_manage will ignore it` can appear during Unity/Mono shutdown. Treat them as secondary unless the same log also contains a real compile, exception, or test failure such as `CS#### error`, `Exception`, or `Test run failed`.