From a5cde46710e76204593f8356bde83e64f0b7be08 Mon Sep 17 00:00:00 2001 From: SilverSupplier <192233040+SilverSupplier@users.noreply.github.com> Date: Mon, 18 May 2026 01:13:24 +0900 Subject: [PATCH 1/2] feat: differentiate floor reaction gameplay --- .../Scripts/Runtime/ExamGameController.cs | 125 ++++++++++++++++-- .../PlayMode/MagicExamHallSceneSmokeTests.cs | 30 +++++ 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs index ec0f9c3..6c35a1e 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs @@ -48,6 +48,7 @@ public sealed class ExamGameController : MonoBehaviour public int ActiveSealCount => seals.Count; public int ActiveGoalCount => activeGoals.Count; public Vector2 PlayerPosition => player == null ? Vector2.zero : player.position; + public Vector2 SafePositionForTests => safePosition; public bool HasEndingReport => reportPanel != null && reportPanel.gameObject.activeSelf; public bool IsDrawingPanelVisible => false; public bool IsResultPanelVisible => false; @@ -459,10 +460,75 @@ private void ActivateGoal(WorldStateGoal goal, string effect) { goal.body.transform.localScale *= 1.15f; } + ApplyGoalReaction(goal); endingReport.RecordDiscovery(goal.id, effect); pulses.Add(new ParticlePulse(goal.position, goal.color)); } + private void ApplyGoalReaction(WorldStateGoal goal) + { + switch (goal.reactionKind) + { + case WorldReactionKind.BridgeFlow: + CreateBridgeReaction(goal); + break; + case WorldReactionKind.HazardStabilizer: + StabilizeHazardReaction(goal); + break; + } + } + + private void CreateBridgeReaction(WorldStateGoal goal) + { + var direction = goal.position.sqrMagnitude < 0.01f ? Vector2.up : goal.position.normalized; + var midpoint = goal.position * 0.5f; + var span = CreateWorldSprite( + $"Flow Bridge {goal.id}", + midpoint, + new Vector3(0.42f, Mathf.Max(goal.position.magnitude, 1f), 1f), + new Color(0.10f, 0.36f, 0.46f), + Color.Lerp(goal.color, Color.white, 0.35f), + PixelSpriteKind.Rug, + -3); + span.transform.rotation = Quaternion.Euler(0f, 0f, Vector2.SignedAngle(Vector2.up, direction)); + floorObjects.Add(span); + + var node = CreateWorldSprite( + $"Flow Node {goal.id}", + goal.position, + Vector3.one * 0.72f, + Color.Lerp(goal.color, Color.white, 0.15f), + Color.white, + PixelSpriteKind.Pulse, + 5); + floorObjects.Add(node); + } + + private void StabilizeHazardReaction(WorldStateGoal goal) + { + safePosition = goal.position; + var orderedHazards = activeHazards + .OrderBy(hazard => Vector2.Distance(hazard.position, goal.position)) + .ToList(); + + for (var index = 0; index < orderedHazards.Count; index++) + { + var hazard = orderedHazards[index]; + hazard.Stabilize(index == 0 ? 0.74f : 0.90f); + pulses.Add(new ParticlePulse(hazard.position, Color.Lerp(hazard.color, goal.color, 0.35f), weak: index > 0)); + } + + var pin = CreateWorldSprite( + $"Stability Pin {goal.id}", + goal.position, + Vector3.one * 0.68f, + goal.color, + Color.white, + PixelSpriteKind.Target, + 6); + floorObjects.Add(pin); + } + private void EvaluateFloorCompletion() { if (!activeGoals.All(goal => goal.completed) || pendingAdvanceAt > 0f || HasEndingReport) @@ -1077,34 +1143,34 @@ public static List BuildAll() { number = 3, title = "흐름층", - objective = "base + overlay 조합으로 끊어진 공중 다리의 네 경로를 연결하세요.", - entryNote = "노트: 길은 하나가 아니다. 같은 목표도 여러 문법으로 이어진다.", - completeNote = "다리 조각들이 이어져 발밑에 경로가 생겼습니다.", + 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), "물과 얼음이 잠깐 딛고 설 길을 만듭니다.") + WorldStateGoal.Combo("brace_bridge", "보강 지지대", SpellFamily.Earth, OverlayOperator.SteelBrace, new Vector2(-4.6f, 1.8f), new Color(0.74f, 0.55f, 0.32f), "땅과 보강이 공중 다리의 첫 흐름을 받쳐 줍니다.").WithReaction(WorldReactionKind.BridgeFlow), + WorldStateGoal.Combo("axis_bridge", "축 정렬 발판", SpellFamily.Wind, OverlayOperator.MartialAxis, new Vector2(4.6f, 1.8f), new Color(0.44f, 0.72f, 0.74f), "바람과 축이 공중 다리의 방향을 맞추며 경로를 엽니다.").WithReaction(WorldReactionKind.BridgeFlow), + WorldStateGoal.Combo("vine_bridge", "덩굴 고리", SpellFamily.Life, OverlayOperator.SoulDot, new Vector2(-3.2f, -2.3f), new Color(0.35f, 0.86f, 0.42f), "생명과 집중이 다리 아래를 묶는 흐름 고리를 만듭니다.").WithReaction(WorldReactionKind.BridgeFlow), + WorldStateGoal.Combo("ice_bridge", "얼음 다리", SpellFamily.Water, OverlayOperator.IceBar, new Vector2(3.2f, -2.3f), new Color(0.48f, 0.84f, 1f), "물과 얼음이 빛나는 발판을 굳혀 공중 다리를 완성합니다.").WithReaction(WorldReactionKind.BridgeFlow) } }, new() { number = 4, title = "균열층", - objective = "위험한 균열을 피해 룬 폭주를 안정화하세요.", - entryNote = "노트: 실패는 떨어지는 것이 아니라, 안전한 자리에서 다시 보는 일이다.", - completeNote = "균열의 박동이 잦아들고 통로가 안정됩니다.", + 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), "번개 갈래가 남은 전하를 흩습니다.") + WorldStateGoal.Combo("earth_stable", "흔들림 고정", SpellFamily.Earth, OverlayOperator.SteelBrace, new Vector2(-5.2f, 2.4f), new Color(0.74f, 0.55f, 0.32f), "땅과 보강이 이번에는 공중 다리가 아니라 균열 가장자리를 고정합니다. 새 안전 지점이 생깁니다.").WithReaction(WorldReactionKind.HazardStabilizer), + WorldStateGoal.Overlay("ice_still", "냉각 정지", OverlayOperator.IceBar, new Vector2(-1.7f, 2.9f), new Color(0.48f, 0.84f, 1f), "얼음 막대가 폭주의 열을 낮추고 가까운 균열 반경을 줄입니다.").WithReaction(WorldReactionKind.HazardStabilizer), + WorldStateGoal.Overlay("void_split", "오염 분리", OverlayOperator.VoidCut, new Vector2(1.8f, 2.9f), new Color(0.58f, 0.42f, 0.92f), "절단이 위험한 흐름을 끊어 내며 재시작 위치를 앞으로 당깁니다.").WithReaction(WorldReactionKind.HazardStabilizer), + WorldStateGoal.Overlay("fork_ground", "전도 분산", OverlayOperator.ElectricFork, new Vector2(5.2f, 2.4f), new Color(1f, 0.9f, 0.22f), "번개 갈래가 남은 전하를 흩고 균열의 위협을 더 작게 만듭니다.").WithReaction(WorldReactionKind.HazardStabilizer) }, hazards = { @@ -1148,6 +1214,7 @@ public sealed class WorldStateGoal public SpellFamily? comboBase; public OverlayOperator? comboOverlay; public string discoveryNote; + public WorldReactionKind reactionKind; public bool completed; public float radius = 2.15f; public float visualScale = 1f; @@ -1194,6 +1261,12 @@ public static WorldStateGoal Combo(string id, string title, SpellFamily family, }; } + public WorldStateGoal WithReaction(WorldReactionKind reactionKind) + { + this.reactionKind = reactionKind; + return this; + } + public bool MatchesBase(SpellFamily family, Vector2 center) { return requiredBase == family && Vector2.Distance(center, position) <= radius; @@ -1217,12 +1290,20 @@ public WorldStateGoal Clone() requiredOverlay = requiredOverlay, comboBase = comboBase, comboOverlay = comboOverlay, + reactionKind = reactionKind, radius = radius, visualScale = visualScale }; } } + public enum WorldReactionKind + { + None, + BridgeFlow, + HazardStabilizer + } + public sealed class HazardZone { public string title; @@ -1244,6 +1325,22 @@ public HazardZone Clone() return new HazardZone(title, position, radius, color); } + public void Stabilize(float radiusMultiplier) + { + radius = Mathf.Max(0.58f, radius * radiusMultiplier); + color = Color.Lerp(color, new Color(0.46f, 0.30f, 0.28f), 0.32f); + if (body == null) + { + return; + } + + var renderer = body.GetComponent(); + if (renderer != null) + { + renderer.color = new Color(1f, 1f, 1f, 0.68f); + } + } + public void Tick(float time) { if (body == null) diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs index ce7a9a6..4c49a1f 100644 --- a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs @@ -119,6 +119,36 @@ public IEnumerator SuccessAfterBaseHintKeepsAssistedFeedback() Assert.That(controller.ActiveSealCount, Is.EqualTo(1)); } + [UnityTest] + public IEnumerator SameComboReadsAsBridgeOnFloorThreeAndStabilizerOnFloorFour() + { + SceneManager.LoadScene("MagicExamHall"); + yield return null; + yield return null; + + var controller = Object.FindFirstObjectByType(); + Assert.That(controller, Is.Not.Null); + + controller.LoadFloorForTests(2); + controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); + controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, Vector2.zero); + yield return null; + + Assert.That(controller.CurrentFloorNumber, Is.EqualTo(3)); + Assert.That(controller.LastMagicNoteText, Does.Contain("공중 다리")); + Assert.That(controller.LastMagicNoteText, Does.Contain("흐름")); + + controller.LoadFloorForTests(3); + controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); + controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, Vector2.zero); + yield return null; + + Assert.That(controller.CurrentFloorNumber, Is.EqualTo(4)); + Assert.That(controller.LastMagicNoteText, Does.Contain("균열")); + Assert.That(controller.LastMagicNoteText, Does.Contain("안전 지점")); + Assert.That(Vector2.Distance(controller.SafePositionForTests, new Vector2(-5.2f, 2.4f)), Is.LessThan(0.01f)); + } + [UnityTest] public IEnumerator FloorTransitionsHazardResetAndEndingReportWork() { From 8fe87275c0cbe4e751f13ff0f1912e38ae74c5f7 Mon Sep 17 00:00:00 2001 From: SilverSupplier <192233040+SilverSupplier@users.noreply.github.com> Date: Mon, 18 May 2026 02:01:31 +0900 Subject: [PATCH 2/2] fix: require local floor reaction casts --- .../Scripts/Runtime/ExamGameController.cs | 19 +++++++++++++++++-- .../PlayMode/MagicExamHallSceneSmokeTests.cs | 14 +++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs index 6c35a1e..50ccbe1 100644 --- a/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs +++ b/unity/MagicExamHall/Assets/MagicExamHall/Scripts/Runtime/ExamGameController.cs @@ -47,6 +47,7 @@ public sealed class ExamGameController : MonoBehaviour public int FloorCount => floorController?.FloorCount ?? 5; public int ActiveSealCount => seals.Count; public int ActiveGoalCount => activeGoals.Count; + public int CompletedGoalCountForTests => activeGoals.Count(goal => goal.completed); public Vector2 PlayerPosition => player == null ? Vector2.zero : player.position; public Vector2 SafePositionForTests => safePosition; public bool HasEndingReport => reportPanel != null && reportPanel.gameObject.activeSelf; @@ -1204,6 +1205,9 @@ public static List BuildAll() public sealed class WorldStateGoal { + private const float OverlayGoalRadius = 1.45f; + private const float ComboGoalRadius = 2.05f; + public string id; public string title; public Vector2 position; @@ -1245,7 +1249,7 @@ public static WorldStateGoal Overlay(string id, string title, OverlayOperator op return new WorldStateGoal(id, title, position, color, PixelSpriteKind.RuneCircle, note) { requiredOverlay = op, - radius = 99f, + radius = OverlayGoalRadius, visualScale = 0.75f }; } @@ -1256,7 +1260,7 @@ public static WorldStateGoal Combo(string id, string title, SpellFamily family, { comboBase = family, comboOverlay = op, - radius = 99f, + radius = ComboGoalRadius, visualScale = 0.85f }; } @@ -1274,6 +1278,11 @@ public bool MatchesBase(SpellFamily family, Vector2 center) public bool MatchesOverlay(CompiledSeal seal, OverlayOperator op, Vector2 center) { + if (!CastTouchedGoalArea(seal, center)) + { + return false; + } + if (requiredOverlay == op) { return true; @@ -1282,6 +1291,12 @@ public bool MatchesOverlay(CompiledSeal seal, OverlayOperator op, Vector2 center return comboBase == seal.baseFamily && comboOverlay == op; } + private bool CastTouchedGoalArea(CompiledSeal seal, Vector2 center) + { + return Vector2.Distance(center, position) <= radius || + Vector2.Distance(seal.worldCenter, position) <= radius; + } + public WorldStateGoal Clone() { return new WorldStateGoal(id, title, position, color, kind, discoveryNote) diff --git a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs index 4c49a1f..7917e60 100644 --- a/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs +++ b/unity/MagicExamHall/Assets/Tests/PlayMode/MagicExamHallSceneSmokeTests.cs @@ -133,20 +133,28 @@ public IEnumerator SameComboReadsAsBridgeOnFloorThreeAndStabilizerOnFloorFour() controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, Vector2.zero); yield return null; + Assert.That(controller.CompletedGoalCountForTests, Is.EqualTo(0)); + + var bridgePosition = new Vector2(-4.6f, 1.8f); + controller.LoadFloorForTests(2); + controller.CastSyntheticBaseForTests(SpellFamily.Earth, bridgePosition); + controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, bridgePosition); + yield return null; Assert.That(controller.CurrentFloorNumber, Is.EqualTo(3)); Assert.That(controller.LastMagicNoteText, Does.Contain("공중 다리")); Assert.That(controller.LastMagicNoteText, Does.Contain("흐름")); + var stabilizerPosition = new Vector2(-5.2f, 2.4f); controller.LoadFloorForTests(3); - controller.CastSyntheticBaseForTests(SpellFamily.Earth, Vector2.zero); - controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, Vector2.zero); + controller.CastSyntheticBaseForTests(SpellFamily.Earth, stabilizerPosition); + controller.CastSyntheticOverlayForTests(OverlayOperator.SteelBrace, stabilizerPosition); yield return null; Assert.That(controller.CurrentFloorNumber, Is.EqualTo(4)); Assert.That(controller.LastMagicNoteText, Does.Contain("균열")); Assert.That(controller.LastMagicNoteText, Does.Contain("안전 지점")); - Assert.That(Vector2.Distance(controller.SafePositionForTests, new Vector2(-5.2f, 2.4f)), Is.LessThan(0.01f)); + Assert.That(Vector2.Distance(controller.SafePositionForTests, stabilizerPosition), Is.LessThan(0.01f)); } [UnityTest]