diff --git a/Assets/Resources/BalanceConfig.asset b/Assets/Resources/BalanceConfig.asset index 0331bd1f..bf286e57 100644 --- a/Assets/Resources/BalanceConfig.asset +++ b/Assets/Resources/BalanceConfig.asset @@ -69,13 +69,16 @@ MonoBehaviour: minDragDistance: 0.35 finalHitTolerance: 0.15 moveSpeed: 9 - boardSize: {x: 12, y: 16} + boardSize: {x: 20, y: 32} + cameraViewHeight: 18 + cameraMinViewHeight: 10 + cameraMaxViewHeight: 36 playerSpawnPoints: - - {x: -4, y: -5.5} - - {x: -2, y: -5.5} - - {x: 0, y: -5.5} - - {x: 2, y: -5.5} - - {x: 4, y: -5.5} + - {x: -4, y: -13} + - {x: -2, y: -13} + - {x: 0, y: -13} + - {x: 2, y: -13} + - {x: 4, y: -13} heroes: - name: "\uC720\uBE44" type: 0 @@ -101,184 +104,236 @@ MonoBehaviour: - title: "\uD669\uAC74\uC801\uC758 \uB09C" subtitle: "184\uB144" aiAccuracy: 0.5 + aiAggroRange: 11 enemies: - name: "\uD669\uAC74\uC801 \uBCF4\uBCD1" type: 0 - position: {x: -3.5, y: 2.5} + position: {x: -3, y: -6} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uD669\uAC74\uC801 \uBCF4\uBCD1" type: 0 - position: {x: 3.5, y: 2.5} + position: {x: 3, y: -6} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uD669\uAC74\uC801 \uCC3D\uBCD1" type: 3 - position: {x: 0, y: 2} + position: {x: 0, y: -4.5} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uD669\uAC74\uC801 \uAD81\uBCD1" type: 2 - position: {x: -2, y: 5} + position: {x: -4, y: 1} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uD669\uAC74\uC801 \uAD81\uBCD1" type: 2 - position: {x: 2, y: 5} + position: {x: 4, y: 1} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uD669\uAC74\uC801 \uBCF4\uBCD1" + type: 0 + position: {x: 0, y: 2.5} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uD669\uAC74\uC801 \uCC3D\uBCD1" + type: 3 + position: {x: -2.5, y: 8} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC7A5\uAC01" type: 0 - position: {x: 0, y: 6.5} + position: {x: 0, y: 10} hpMult: 1.3 atkMult: 1.2 isBoss: 1 - title: "\uD638\uB85C\uAD00 \uC804\uD22C" subtitle: "190\uB144" aiAccuracy: 0.6 + aiAggroRange: 11 enemies: - name: "\uB3D9\uD0C1\uAD70 \uCC3D\uBCD1" type: 3 - position: {x: -2, y: 2.2} + position: {x: -2.5, y: -5} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uB3D9\uD0C1\uAD70 \uCC3D\uBCD1" type: 3 - position: {x: 2, y: 2.2} + position: {x: 2.5, y: -5} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uB3D9\uD0C1\uAD70 \uAE30\uBCD1" type: 1 - position: {x: -4, y: 3.5} + position: {x: -6, y: -1} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uB3D9\uD0C1\uAD70 \uAE30\uBCD1" type: 1 - position: {x: 4, y: 3.5} + position: {x: 6, y: -1} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uB3D9\uD0C1\uAD70 \uBCF4\uBCD1" + type: 0 + position: {x: 0, y: 1} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uB3D9\uD0C1\uAD70 \uAD81\uBCD1" type: 2 - position: {x: -2.5, y: 5.5} + position: {x: -3, y: 3} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uB3D9\uD0C1\uAD70 \uAD81\uBCD1" type: 2 - position: {x: 2.5, y: 5.5} + position: {x: 3, y: 3} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uB3D9\uD0C1\uAD70 \uAE30\uBCD1" + type: 1 + position: {x: 0, y: 7} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC5EC\uD3EC" type: 1 - position: {x: 0, y: 4.5} + position: {x: 0, y: 11} hpMult: 1.6 atkMult: 1.5 isBoss: 1 - title: "\uC801\uBCBD\uB300\uC804" subtitle: "208\uB144" aiAccuracy: 0.65 + aiAggroRange: 12 enemies: - name: "\uC870\uC870\uAD70 \uBCF4\uBCD1" type: 0 - position: {x: -4, y: 2.5} + position: {x: -5, y: -6} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC870\uC870\uAD70 \uBCF4\uBCD1" type: 0 - position: {x: 0, y: 2} + position: {x: 0, y: -5} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC870\uC870\uAD70 \uBCF4\uBCD1" type: 0 - position: {x: 4, y: 2.5} - hpMult: 1 - atkMult: 1 - isBoss: 0 - - name: "\uC870\uC870\uAD70 \uAD81\uBCD1" - type: 2 - position: {x: -2.5, y: 5} - hpMult: 1 - atkMult: 1 - isBoss: 0 - - name: "\uC870\uC870\uAD70 \uAD81\uBCD1" - type: 2 - position: {x: 2.5, y: 5} + position: {x: 5, y: -6} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC870\uC870\uAD70 \uAE30\uBCD1" type: 1 - position: {x: 0, y: 4.2} + position: {x: -3, y: 0} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uC870\uC870\uAD70 \uAE30\uBCD1" + type: 1 + position: {x: 3, y: 0} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uC870\uC870\uAD70 \uAD81\uBCD1" + type: 2 + position: {x: -4, y: 5} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uC870\uC870\uAD70 \uAD81\uBCD1" + type: 2 + position: {x: 4, y: 5} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uC870\uC870\uAD70 \uCC3D\uBCD1" + type: 3 + position: {x: 0, y: 7} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC870\uC870" type: 0 - position: {x: 0, y: 6.8} + position: {x: 0, y: 12} hpMult: 1.5 atkMult: 1.3 isBoss: 1 - title: "\uC624\uC7A5\uC6D0 \uC804\uD22C" subtitle: "234\uB144" aiAccuracy: 0.7 + aiAggroRange: 12 enemies: - name: "\uC704\uAD70 \uCC3D\uBCD1" type: 3 - position: {x: -2.5, y: 2.2} + position: {x: -3, y: -6} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC704\uAD70 \uCC3D\uBCD1" type: 3 - position: {x: 2.5, y: 2.2} + position: {x: 3, y: -6} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC704\uAD70 \uBCF4\uBCD1" type: 0 - position: {x: -4.5, y: 3.5} + position: {x: -7, y: -2} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC704\uAD70 \uBCF4\uBCD1" type: 0 - position: {x: 4.5, y: 3.5} + position: {x: 7, y: -2} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC704\uAD70 \uAE30\uBCD1" type: 1 - position: {x: -1.5, y: 4.5} + position: {x: -2, y: 2} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC704\uAD70 \uAE30\uBCD1" type: 1 - position: {x: 1.5, y: 4.5} + position: {x: 2, y: 2} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC704\uAD70 \uAD81\uBCD1" type: 2 - position: {x: 0, y: 6} + position: {x: -4, y: 7} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uC704\uAD70 \uAD81\uBCD1" + type: 2 + position: {x: 4, y: 7} + hpMult: 1 + atkMult: 1 + isBoss: 0 + - name: "\uC704\uAD70 \uCC3D\uBCD1" + type: 3 + position: {x: 0, y: 9} hpMult: 1 atkMult: 1 isBoss: 0 - name: "\uC0AC\uB9C8\uC758" type: 0 - position: {x: 0, y: 7.2} + position: {x: 0, y: 13} hpMult: 1.6 atkMult: 1.3 isBoss: 1 diff --git a/Assets/Scenes/Battle.unity b/Assets/Scenes/Battle.unity index 85d8cb73..e3c37d31 100644 --- a/Assets/Scenes/Battle.unity +++ b/Assets/Scenes/Battle.unity @@ -24,9 +24,9 @@ RenderSettings: 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_AmbientMode: 3 m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} - m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_SkyboxMaterial: {fileID: 0} m_HaloStrength: 0.5 m_FlareStrength: 1 m_FlareFadeSpeed: 3 @@ -50,7 +50,7 @@ LightmapSettings: m_IndirectOutputScale: 1 m_AlbedoBoost: 1 m_EnvironmentLightingMode: 0 - m_EnableBakedLightmaps: 1 + m_EnableBakedLightmaps: 0 m_EnableRealtimeLightmaps: 0 m_LightmapEditorSettings: serializedVersion: 12 @@ -119,7 +119,7 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} ---- !u!1 &930732900 +--- !u!1 &1288297926 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -127,9 +127,56 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 930732903} - - component: {fileID: 930732902} - - component: {fileID: 930732901} + - component: {fileID: 1288297928} + - component: {fileID: 1288297927} + m_Layer: 0 + m_Name: GameBootstrap + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1288297927 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1288297926} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0d49dc40532d984485290e1cfc0f91f, type: 3} + m_Name: + m_EditorClassIdentifier: TKS.Runtime::TKS.GameBootstrap + Config: {fileID: 11400000, guid: 6b41999f2857842408b1a9edeaf47cb8, type: 2} + AutoStart: 1 + StartStageIndex: 0 +--- !u!4 &1288297928 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1288297926} + 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 &1938987432 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1938987435} + - component: {fileID: 1938987434} + - component: {fileID: 1938987433} m_Layer: 0 m_Name: Main Camera m_TagString: MainCamera @@ -137,21 +184,21 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!81 &930732901 +--- !u!81 &1938987433 AudioListener: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 930732900} + m_GameObject: {fileID: 1938987432} m_Enabled: 1 ---- !u!20 &930732902 +--- !u!20 &1938987434 Camera: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 930732900} + m_GameObject: {fileID: 1938987432} m_Enabled: 1 serializedVersion: 2 m_ClearFlags: 2 @@ -196,13 +243,13 @@ Camera: m_OcclusionCulling: 1 m_StereoConvergence: 10 m_StereoSeparation: 0.022 ---- !u!4 &930732903 +--- !u!4 &1938987435 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 930732900} + m_GameObject: {fileID: 1938987432} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: -10} @@ -211,56 +258,9 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1640921812 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1640921814} - - component: {fileID: 1640921813} - m_Layer: 0 - m_Name: GameBootstrap - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &1640921813 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1640921812} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: d0d49dc40532d984485290e1cfc0f91f, type: 3} - m_Name: - m_EditorClassIdentifier: TKS.Runtime::TKS.GameBootstrap - Config: {fileID: 11400000, guid: 6b41999f2857842408b1a9edeaf47cb8, type: 2} - AutoStart: 1 - StartStageIndex: 0 ---- !u!4 &1640921814 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1640921812} - 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: 930732903} - - {fileID: 1640921814} + - {fileID: 1938987435} + - {fileID: 1288297928} diff --git a/Assets/Scripts/Editor/BalanceDefaults.cs b/Assets/Scripts/Editor/BalanceDefaults.cs index 7a69dff5..c895e87e 100644 --- a/Assets/Scripts/Editor/BalanceDefaults.cs +++ b/Assets/Scripts/Editor/BalanceDefaults.cs @@ -5,7 +5,7 @@ namespace TKS.EditorTools { /// /// BalanceConfig 자산의 최초 1회 시드 값(프로토타입 초안). 이후 수치 조정은 자산(Inspector)에서 한다 — 코드가 SOT 가 아니다. - /// 전장 = 가로 12 × 세로 16 (x −6~6 · y −8~8). 아군은 아래쪽(y −5.5), 적은 위쪽 절반. + /// 전장 = 가로 20 × 세로 32 (x −10~10 · y −16~16) · 화면에는 세로 18 만 보여 스크롤한다. 아군은 아래(y −13) · 적은 전위/중군/후군으로 흩어 배치. /// public static class BalanceDefaults { @@ -44,10 +44,13 @@ namespace TKS.EditorTools c.finalHitTolerance = 0.15f; c.moveSpeed = 9f; - c.boardSize = new Vector2(12f, 16f); + c.boardSize = new Vector2(20f, 32f); + c.cameraViewHeight = 18f; + c.cameraMinViewHeight = 10f; + c.cameraMaxViewHeight = 36f; c.playerSpawnPoints = new[] { - new Vector2(-4f, -5.5f), new Vector2(-2f, -5.5f), new Vector2(0f, -5.5f), new Vector2(2f, -5.5f), new Vector2(4f, -5.5f), + new Vector2(-4f, -13f), new Vector2(-2f, -13f), new Vector2(0f, -13f), new Vector2(2f, -13f), new Vector2(4f, -13f), }; c.heroes = new[] @@ -61,38 +64,49 @@ namespace TKS.EditorTools c.stages = new[] { - Stage("황건적의 난", "184년", 0.5f, - E("황건적 보병", UnitType.Infantry, -3.5f, 2.5f), - E("황건적 보병", UnitType.Infantry, 3.5f, 2.5f), - E("황건적 창병", UnitType.Spear, 0f, 2.0f), - E("황건적 궁병", UnitType.Archer, -2f, 5f), - E("황건적 궁병", UnitType.Archer, 2f, 5f), - Boss("장각", UnitType.Infantry, 0f, 6.5f, 1.3f, 1.2f)), - Stage("호로관 전투", "190년", 0.6f, - E("동탁군 창병", UnitType.Spear, -2f, 2.2f), - E("동탁군 창병", UnitType.Spear, 2f, 2.2f), - E("동탁군 기병", UnitType.Cavalry, -4f, 3.5f), - E("동탁군 기병", UnitType.Cavalry, 4f, 3.5f), - E("동탁군 궁병", UnitType.Archer, -2.5f, 5.5f), - E("동탁군 궁병", UnitType.Archer, 2.5f, 5.5f), - Boss("여포", UnitType.Cavalry, 0f, 4.5f, 1.6f, 1.5f)), - Stage("적벽대전", "208년", 0.65f, - E("조조군 보병", UnitType.Infantry, -4f, 2.5f), - E("조조군 보병", UnitType.Infantry, 0f, 2.0f), - E("조조군 보병", UnitType.Infantry, 4f, 2.5f), - E("조조군 궁병", UnitType.Archer, -2.5f, 5f), - E("조조군 궁병", UnitType.Archer, 2.5f, 5f), - E("조조군 기병", UnitType.Cavalry, 0f, 4.2f), - Boss("조조", UnitType.Infantry, 0f, 6.8f, 1.5f, 1.3f)), - Stage("오장원 전투", "234년", 0.7f, - E("위군 창병", UnitType.Spear, -2.5f, 2.2f), - E("위군 창병", UnitType.Spear, 2.5f, 2.2f), - E("위군 보병", UnitType.Infantry, -4.5f, 3.5f), - E("위군 보병", UnitType.Infantry, 4.5f, 3.5f), - E("위군 기병", UnitType.Cavalry, -1.5f, 4.5f), - E("위군 기병", UnitType.Cavalry, 1.5f, 4.5f), - E("위군 궁병", UnitType.Archer, 0f, 6f), - Boss("사마의", UnitType.Infantry, 0f, 7.2f, 1.6f, 1.3f)), + Stage("황건적의 난", "184년", 0.5f, 11f, + // 전위 + E("황건적 보병", UnitType.Infantry, -3f, -6f), + E("황건적 보병", UnitType.Infantry, 3f, -6f), + E("황건적 창병", UnitType.Spear, 0f, -4.5f), + // 중군 + E("황건적 궁병", UnitType.Archer, -4f, 1f), + E("황건적 궁병", UnitType.Archer, 4f, 1f), + E("황건적 보병", UnitType.Infantry, 0f, 2.5f), + // 후군 + E("황건적 창병", UnitType.Spear, -2.5f, 8f), + Boss("장각", UnitType.Infantry, 0f, 10f, 1.3f, 1.2f)), + Stage("호로관 전투", "190년", 0.6f, 11f, + E("동탁군 창병", UnitType.Spear, -2.5f, -5f), + E("동탁군 창병", UnitType.Spear, 2.5f, -5f), + E("동탁군 기병", UnitType.Cavalry, -6f, -1f), + E("동탁군 기병", UnitType.Cavalry, 6f, -1f), + E("동탁군 보병", UnitType.Infantry, 0f, 1f), + E("동탁군 궁병", UnitType.Archer, -3f, 3f), + E("동탁군 궁병", UnitType.Archer, 3f, 3f), + E("동탁군 기병", UnitType.Cavalry, 0f, 7f), + Boss("여포", UnitType.Cavalry, 0f, 11f, 1.6f, 1.5f)), + Stage("적벽대전", "208년", 0.65f, 12f, + E("조조군 보병", UnitType.Infantry, -5f, -6f), + E("조조군 보병", UnitType.Infantry, 0f, -5f), + E("조조군 보병", UnitType.Infantry, 5f, -6f), + E("조조군 기병", UnitType.Cavalry, -3f, 0f), + E("조조군 기병", UnitType.Cavalry, 3f, 0f), + E("조조군 궁병", UnitType.Archer, -4f, 5f), + E("조조군 궁병", UnitType.Archer, 4f, 5f), + E("조조군 창병", UnitType.Spear, 0f, 7f), + Boss("조조", UnitType.Infantry, 0f, 12f, 1.5f, 1.3f)), + Stage("오장원 전투", "234년", 0.7f, 12f, + E("위군 창병", UnitType.Spear, -3f, -6f), + E("위군 창병", UnitType.Spear, 3f, -6f), + E("위군 보병", UnitType.Infantry, -7f, -2f), + E("위군 보병", UnitType.Infantry, 7f, -2f), + E("위군 기병", UnitType.Cavalry, -2f, 2f), + E("위군 기병", UnitType.Cavalry, 2f, 2f), + E("위군 궁병", UnitType.Archer, -4f, 7f), + E("위군 궁병", UnitType.Archer, 4f, 7f), + E("위군 창병", UnitType.Spear, 0f, 9f), + Boss("사마의", UnitType.Infantry, 0f, 13f, 1.6f, 1.3f)), }; } @@ -116,9 +130,9 @@ namespace TKS.EditorTools return new BalanceConfig.EnemySpawn { name = name, type = t, position = new Vector2(x, y), hpMult = hpMult, atkMult = atkMult, isBoss = true }; } - static BalanceConfig.StageDef Stage(string title, string subtitle, float accuracy, params BalanceConfig.EnemySpawn[] enemies) + static BalanceConfig.StageDef Stage(string title, string subtitle, float accuracy, float aggro, params BalanceConfig.EnemySpawn[] enemies) { - return new BalanceConfig.StageDef { title = title, subtitle = subtitle, aiAccuracy = accuracy, enemies = new List(enemies) }; + return new BalanceConfig.StageDef { title = title, subtitle = subtitle, aiAccuracy = accuracy, aiAggroRange = aggro, enemies = new List(enemies) }; } } } diff --git a/Assets/Scripts/Editor/ProjectSetup.cs b/Assets/Scripts/Editor/ProjectSetup.cs index 2e404a20..798f7e18 100644 --- a/Assets/Scripts/Editor/ProjectSetup.cs +++ b/Assets/Scripts/Editor/ProjectSetup.cs @@ -33,6 +33,17 @@ namespace TKS.EditorTools Debug.Log("[TKS] BalanceConfig 기본값 재시드"); } + /// CLI 용: 기본값 재시드 + 씬·설정 재생성(기존 자산 수치를 덮어쓴다). + public static void BuildFresh() + { + var cfg = EnsureConfig(true); + BuildScene(cfg); + ApplySettings(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log("[TKS] BuildFresh 완료 · 기본값 재시드 · " + ScenePath); + } + public static BalanceConfig EnsureConfig(bool overwrite) { if (!AssetDatabase.IsValidFolder("Assets/Resources")) AssetDatabase.CreateFolder("Assets", "Resources"); @@ -81,6 +92,7 @@ namespace TKS.EditorTools PlayerSettings.companyName = "NerdNavis"; PlayerSettings.productName = "ThreeKingdomsSlingshot"; PlayerSettings.defaultInterfaceOrientation = UIOrientation.Portrait; + PlayerSettings.runInBackground = true; EditorSettings.defaultBehaviorMode = EditorBehaviorMode.Mode2D; } diff --git a/Assets/Scripts/Runtime/AutoPlayDriver.cs b/Assets/Scripts/Runtime/AutoPlayDriver.cs index 454084fa..b42ee8fa 100644 --- a/Assets/Scripts/Runtime/AutoPlayDriver.cs +++ b/Assets/Scripts/Runtime/AutoPlayDriver.cs @@ -8,6 +8,7 @@ namespace TKS /// /// 자동 플레이 실측: 아군도 AI 로 조작해 몇 턴을 돌리며 스크린샷·로그를 남긴다. /// 에디터에서 ProjectSetup.AutoPlayCapture 가 SessionState 로 출력 폴더를 넘기면 생성된다. + /// 비활성 에디터 창은 프레임이 느리므로 timeScale 을 올려 실시간 기준으로 빨리 끝낸다. /// public class AutoPlayDriver : MonoBehaviour { @@ -36,6 +37,8 @@ namespace TKS IEnumerator Start() { Directory.CreateDirectory(OutputDir); + Time.timeScale = 4f; + Time.maximumDeltaTime = 1f; float t0 = Time.realtimeSinceStartup; while ((BattleManager.Instance == null || BattleManager.Instance.Phase != BattlePhase.PlayerTurn) && Time.realtimeSinceStartup - t0 < 20f) @@ -50,22 +53,42 @@ namespace TKS Finish(1); yield break; } + var camCtrl = cam.GetComponent(); - Log("stage=" + bm.Stage.title + " players=" + bm.AliveCount(Faction.Player) + " enemies=" + bm.AliveCount(Faction.Enemy)); + Log("stage=" + bm.Stage.title + " players=" + bm.AliveCount(Faction.Player) + " enemies=" + bm.AliveCount(Faction.Enemy) + " board=" + bm.Config.boardSize); yield return Fx.Wait(0.6f); yield return Shot(cam, "start"); + // 전장 전체 조감(최대 줌아웃) → 원래 줌 + if (camCtrl != null) + { + float prev = camCtrl.ViewHeight; + camCtrl.SetViewHeight(camCtrl.MaxViewHeight); + camCtrl.SnapTo(Vector2.zero); + yield return null; + yield return Shot(cam, "overview_zoom_out"); + camCtrl.SetViewHeight(prev); + camCtrl.PanBy(new Vector2(0f, 6f)); // 스크롤 확인 + yield return null; + yield return Shot(cam, "scrolled_up"); + camCtrl.FocusOn(bm.AliveUnits(Faction.Player)[0].Position + new Vector2(0f, 3f)); + yield return Fx.Wait(0.5f); + } + var first = FirstActable(bm); if (first != null) { - var tgt = EnemyAI.Decide(first, bm.AliveUnits(Faction.Enemy), 1f, bm.Rng, bm.Board); - Vector2 pointer = first.Position - (tgt - first.Position) * 0.5f; - bm.Sling.ShowPreview(first, tgt, pointer); - first.View.SetSelected(true); - yield return null; - yield return Shot(cam, "aim_" + first.DisplayName); - first.View.SetSelected(false); - bm.Sling.HidePreview(); + var tgt = EnemyAI.Decide(first, bm.AliveUnits(Faction.Enemy), 1f, float.PositiveInfinity, bm.Rng, bm.Board); + if (tgt.HasValue) + { + Vector2 pointer = first.Position - (tgt.Value - first.Position) * 0.5f; + bm.Sling.ShowPreview(first, tgt.Value, pointer); + first.View.SetSelected(true); + yield return null; + yield return Shot(cam, "aim_" + first.DisplayName); + first.View.SetSelected(false); + bm.Sling.HidePreview(); + } } bool firstActionShot = false; @@ -80,8 +103,9 @@ namespace TKS bm.OnEmptyTap(); break; } - var target = EnemyAI.Decide(u, bm.AliveUnits(Faction.Enemy), 1f, bm.Rng, bm.Board); - if (!bm.RequestPlayerAction(u, target)) break; + var target = EnemyAI.Decide(u, bm.AliveUnits(Faction.Enemy), 1f, float.PositiveInfinity, bm.Rng, bm.Board); + if (!target.HasValue) break; + if (!bm.RequestPlayerAction(u, target.Value)) break; while (bm.Phase == BattlePhase.Resolving) yield return null; Log("turn " + turn + " | " + bm.LastMessage); if (!firstActionShot) @@ -93,11 +117,11 @@ namespace TKS } while (bm.Phase == BattlePhase.EnemyTurn || bm.Phase == BattlePhase.Resolving) yield return null; Log("turn " + turn + " end | phase=" + bm.Phase + " players=" + bm.AliveCount(Faction.Player) + " enemies=" + bm.AliveCount(Faction.Enemy) + " | " + bm.LastMessage); + yield return Fx.Wait(0.4f); yield return Shot(cam, "turn" + turn + "_end"); if (bm.Phase == BattlePhase.StageClear || bm.Phase == BattlePhase.Defeat) break; } - File.WriteAllLines(Path.Combine(OutputDir, "autoplay_log.txt"), log.ToArray()); Finish(0); } @@ -122,10 +146,6 @@ namespace TKS { log.Add(s); Debug.Log("[AutoPlay] " + s); - } - - void Finish(int code) - { try { File.WriteAllLines(Path.Combine(OutputDir, "autoplay_log.txt"), log.ToArray()); @@ -133,6 +153,12 @@ namespace TKS catch { } + } + + void Finish(int code) + { + Log("finish code=" + code); + Time.timeScale = 1f; #if UNITY_EDITOR UnityEditor.EditorApplication.Exit(code); #else diff --git a/Assets/Scripts/Runtime/BalanceConfig.cs b/Assets/Scripts/Runtime/BalanceConfig.cs index ab01b4d6..01632e65 100644 --- a/Assets/Scripts/Runtime/BalanceConfig.cs +++ b/Assets/Scripts/Runtime/BalanceConfig.cs @@ -51,6 +51,8 @@ namespace TKS public string title; public string subtitle; [Range(0f, 1f)] public float aiAccuracy = 0.6f; + [Tooltip("이 거리 안에 아군이 들어와야 적이 움직인다(넓은 전장에서 부대별 교전)")] + public float aiAggroRange = 11f; public List enemies = new List(); } @@ -82,8 +84,12 @@ namespace TKS public float finalHitTolerance = 0.15f; public float moveSpeed = 9f; - [Header("전장")] - public Vector2 boardSize = new Vector2(12f, 16f); + [Header("전장·카메라 (화면보다 넓은 전장을 스크롤)")] + public Vector2 boardSize = new Vector2(20f, 32f); + [Tooltip("화면 세로에 보이는 월드 높이")] + public float cameraViewHeight = 18f; + public float cameraMinViewHeight = 10f; + public float cameraMaxViewHeight = 36f; public Vector2[] playerSpawnPoints = new Vector2[0]; [Header("아군 영웅 (최대 5)")] diff --git a/Assets/Scripts/Runtime/BattleHUD.cs b/Assets/Scripts/Runtime/BattleHUD.cs index 9b88d53b..f556fc52 100644 --- a/Assets/Scripts/Runtime/BattleHUD.cs +++ b/Assets/Scripts/Runtime/BattleHUD.cs @@ -1,13 +1,16 @@ +using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; namespace TKS { - /// 화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·스킬 아이콘(추후)·결과 오버레이. + /// 화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 아이콘(추후)·결과 오버레이. public class BattleHUD : MonoBehaviour { BattleManager battle; + Camera uiCam; + CameraController camCtrl; Text stageText; Text turnText; Text countText; @@ -19,9 +22,15 @@ namespace TKS Text resultSub; Unit selected; - public void Build(BattleManager b, Camera cam) + Image minimapBg; + Image minimapView; + readonly Dictionary minimapDots = new Dictionary(); + + public void Build(BattleManager b, Camera cam, CameraController cc) { battle = b; + uiCam = cam; + camCtrl = cc; var canvas = gameObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = cam; @@ -58,6 +67,16 @@ namespace TKS skillLabel.text = "스킬\n(추후)"; skillLabel.color = new Color(0.75f, 0.75f, 0.75f); + // 미니맵 (전장 비율 · 탭하면 그 지점으로 카메라 이동) + Vector2 bs = battle.Config.boardSize; + float mmW = 180f; + float mmH = mmW * bs.y / Mathf.Max(0.01f, bs.x); + minimapBg = MakeImage("Minimap", transform, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-30f, -215f), new Vector2(mmW, mmH), new Color(0.06f, 0.1f, 0.06f, 0.8f)); + var mmBorder = MakeImage("Border", minimapBg.transform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(6f, 6f), new Color(0.85f, 0.75f, 0.45f, 0.9f)); + mmBorder.transform.SetAsFirstSibling(); + var mmMid = MakeImage("Mid", minimapBg.transform, new Vector2(0f, 0.5f), new Vector2(1f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(0f, 2f), new Color(1f, 1f, 1f, 0.25f)); + minimapView = MakeImage("View", minimapBg.transform, Vector2.zero, Vector2.zero, Vector2.zero, Vector2.zero, new Vector2(40f, 60f), new Color(1f, 1f, 1f, 0.2f)); + resultOverlay = MakeImage("ResultOverlay", transform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero, new Color(0f, 0f, 0f, 0.65f)).gameObject; resultTitle = MakeText("Title", new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 60f), new Vector2(1000f, 160f), 110, TextAnchor.MiddleCenter); resultTitle.transform.SetParent(resultOverlay.transform, false); @@ -111,10 +130,28 @@ namespace TKS selected = u; } + /// 화면 점이 미니맵 안이면 해당 월드 좌표를 돌려준다. + public bool TryMinimapTap(Vector2 screen, out Vector2 world) + { + world = Vector2.zero; + if (minimapBg == null || battle == null || battle.Board == null) return false; + var rt = minimapBg.rectTransform; + if (!RectTransformUtility.RectangleContainsScreenPoint(rt, screen, uiCam)) return false; + Vector2 local; + if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rt, screen, uiCam, out local)) return false; + var r = rt.rect; + float nx = Mathf.Clamp01((local.x - r.xMin) / r.width); + float ny = Mathf.Clamp01((local.y - r.yMin) / r.height); + var b = battle.Board.Rect; + world = new Vector2(b.xMin + nx * b.width, b.yMin + ny * b.height); + return true; + } + void LateUpdate() { if (battle == null || battle.Config == null || battle.Config.stages.Length == 0) return; Refresh(); + RefreshMinimap(); } void Refresh() @@ -138,7 +175,7 @@ namespace TKS { hintText.text = battle.OnlyBonusMovesRemain() ? "추가 이동: 유닛을 당겨 이동 · 빈 곳을 탭하면 건너뜀" - : "아군 유닛을 잡고 뒤로 당겼다 놓으면 반대 방향으로 이동·공격"; + : "유닛을 잡고 뒤로 당겼다 놓기 = 이동·공격 · 빈 곳 드래그 = 전장 이동"; } else { @@ -178,5 +215,55 @@ namespace TKS : stage.title + " 에서 패배\n화면을 탭하면 다시 도전"; } } + + void RefreshMinimap() + { + if (minimapBg == null || battle.Board == null) return; + var r = minimapBg.rectTransform.rect; + var b = battle.Board.Rect; + float sx = r.width / b.width; + float sy = r.height / b.height; + + // 카메라 시야 사각형 + if (camCtrl != null) + { + var v = camCtrl.ViewRect; + float x0 = Mathf.Clamp((v.xMin - b.xMin) * sx, 0f, r.width); + float y0 = Mathf.Clamp((v.yMin - b.yMin) * sy, 0f, r.height); + float x1 = Mathf.Clamp((v.xMax - b.xMin) * sx, 0f, r.width); + float y1 = Mathf.Clamp((v.yMax - b.yMin) * sy, 0f, r.height); + minimapView.rectTransform.anchoredPosition = new Vector2(x0, y0); + minimapView.rectTransform.sizeDelta = new Vector2(Mathf.Max(2f, x1 - x0), Mathf.Max(2f, y1 - y0)); + } + + // 유닛 점 + foreach (var u in battle.Units) + { + if (u == null) continue; + Image dot; + if (!minimapDots.TryGetValue(u, out dot)) + { + dot = MakeImage("Dot", minimapBg.transform, Vector2.zero, Vector2.zero, new Vector2(0.5f, 0.5f), Vector2.zero, + new Vector2(u.IsBoss ? 14f : 10f, u.IsBoss ? 14f : 10f), + u.Faction == Faction.Player ? UnitView.PlayerColor : UnitView.EnemyColor); + if (u.IsBoss) dot.color = new Color(1f, 0.85f, 0.3f); + minimapDots[u] = dot; + } + bool show = u.IsAlive; + if (dot.gameObject.activeSelf != show) dot.gameObject.SetActive(show); + if (show) dot.rectTransform.anchoredPosition = new Vector2((u.Position.x - b.xMin) * sx, (u.Position.y - b.yMin) * sy); + } + // 사라진 유닛 정리 + var dead = new List(); + foreach (var kv in minimapDots) + { + if (kv.Key == null) dead.Add(kv.Key); + } + foreach (var k in dead) + { + if (minimapDots[k] != null) Destroy(minimapDots[k].gameObject); + minimapDots.Remove(k); + } + } } } diff --git a/Assets/Scripts/Runtime/BattleManager.cs b/Assets/Scripts/Runtime/BattleManager.cs index 2810ea08..b9bc1501 100644 --- a/Assets/Scripts/Runtime/BattleManager.cs +++ b/Assets/Scripts/Runtime/BattleManager.cs @@ -44,7 +44,14 @@ namespace TKS get { return Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; } } + /// 상태가 바뀔 때(HUD 갱신용). public event Action Changed; + /// 단계가 바뀔 때(카메라 연동용). + public event Action PhaseChanged; + /// 어떤 유닛이 행동을 시작할 때(카메라 추적용). + public event Action UnitActing; + /// 스테이지 배치가 끝났을 때. + public event Action StageStarted; void Awake() { @@ -72,6 +79,14 @@ namespace TKS if (Changed != null) Changed(); } + void SetPhase(BattlePhase p) + { + if (Phase == p) return; + Phase = p; + if (PhaseChanged != null) PhaseChanged(p); + NotifyChanged(); + } + // ---------- 유닛 ---------- public List AliveUnits(Faction f) @@ -129,6 +144,7 @@ namespace TKS { StopAllCoroutines(); ClearUnits(); + Phase = BattlePhase.Setup; StageIndex = Mathf.Clamp(index, 0, Config.stages.Length - 1); var stage = Config.stages[StageIndex]; @@ -144,9 +160,9 @@ namespace TKS } TurnNumber = 1; - Phase = BattlePhase.PlayerTurn; LastMessage = stage.title + " — 아군 차례"; - NotifyChanged(); + if (StageStarted != null) StageStarted(); + SetPhase(BattlePhase.PlayerTurn); } public void ContinueAfterResult() @@ -171,8 +187,8 @@ namespace TKS public bool RequestPlayerAction(Unit u, Vector2 target) { if (!CanAct(u)) return false; - Phase = BattlePhase.Resolving; - NotifyChanged(); + SetPhase(BattlePhase.Resolving); + if (UnitActing != null) UnitActing(u); StartCoroutine(PlayerActionRoutine(u, target)); return true; } @@ -196,8 +212,7 @@ namespace TKS } else { - Phase = BattlePhase.PlayerTurn; - NotifyChanged(); + SetPhase(BattlePhase.PlayerTurn); } } @@ -231,8 +246,7 @@ namespace TKS { if (u.BonusMoveAvailable) u.MarkActed(); } - Phase = BattlePhase.Resolving; - NotifyChanged(); + SetPhase(BattlePhase.Resolving); StartCoroutine(EnemyTurnRoutine()); } @@ -245,14 +259,16 @@ namespace TKS IEnumerator EnemyTurnRoutine() { - Phase = BattlePhase.EnemyTurn; + SetPhase(BattlePhase.EnemyTurn); LastMessage = "적군 차례"; NotifyChanged(); foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn(); - yield return Fx.Wait(0.5f); + yield return Fx.Wait(0.4f); var enemies = AliveUnits(Faction.Enemy); float accuracy = Stage.aiAccuracy; + float aggro = Stage.aiAggroRange; + int acted = 0; for (int i = 0; i < enemies.Count; i++) { var e = enemies[i]; @@ -260,21 +276,26 @@ namespace TKS var players = AliveUnits(Faction.Player); if (players.Count == 0) break; - Vector2 target = EnemyAI.Decide(e, players, accuracy, Rng, Board); + Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board); + if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴 + + if (UnitActing != null) UnitActing(e); + yield return Fx.Wait(0.3f); // 카메라가 따라올 시간 var o = new ActionOutcome(); - yield return Resolver.Resolve(e, target, false, o); + yield return Resolver.Resolve(e, target.Value, false, o); yield return ApplyOverwatch(e, o.Path); LastMessage = Describe(e, o, false); NotifyChanged(); + acted++; if (CheckBattleEnd()) yield break; yield return Fx.Wait(0.25f); } + if (acted == 0) yield return Fx.Wait(0.3f); foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn(); TurnNumber++; - Phase = BattlePhase.PlayerTurn; - LastMessage = TurnNumber + "번째 턴 — 아군 차례"; - NotifyChanged(); + LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : ""); + SetPhase(BattlePhase.PlayerTurn); } /// 창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회). @@ -314,16 +335,14 @@ namespace TKS { if (AliveCount(Faction.Enemy) == 0) { - Phase = BattlePhase.StageClear; LastMessage = "승리 — 전장의 적을 모두 처치했습니다"; - NotifyChanged(); + SetPhase(BattlePhase.StageClear); return true; } if (AliveCount(Faction.Player) == 0) { - Phase = BattlePhase.Defeat; LastMessage = "패배 — 아군이 전멸했습니다"; - NotifyChanged(); + SetPhase(BattlePhase.Defeat); return true; } return false; @@ -338,7 +357,7 @@ namespace TKS public void ForcePhase(BattlePhase p) { - Phase = p; + SetPhase(p); NotifyChanged(); } diff --git a/Assets/Scripts/Runtime/Board.cs b/Assets/Scripts/Runtime/Board.cs index a1781625..9799f2db 100644 --- a/Assets/Scripts/Runtime/Board.cs +++ b/Assets/Scripts/Runtime/Board.cs @@ -7,7 +7,7 @@ namespace TKS { } - /// 전장(바닥·테두리·4면 벽 콜라이더). + /// 전장(바닥·격자·테두리·4면 벽 콜라이더). 화면보다 넓어 카메라로 스크롤한다. public class Board : MonoBehaviour { public Vector2 Size { get; private set; } @@ -31,12 +31,28 @@ namespace TKS enemyHalf.transform.localScale = new Vector3(w, h * 0.5f, 1f); enemyHalf.transform.localPosition = new Vector3(0f, h * 0.25f, 0f); - var mid = ProceduralSprites.NewSprite(transform, "MidLine", ProceduralSprites.Square, new Color(1f, 1f, 1f, 0.12f), 1f, -18); - mid.transform.localScale = new Vector3(w, 0.04f, 1f); + // 격자(4 단위) — 스크롤 체감용 + var gridColor = new Color(1f, 1f, 1f, 0.07f); + const float gridStep = 4f; + for (float x = -w * 0.5f + gridStep; x < w * 0.5f - 0.01f; x += gridStep) + { + var g = ProceduralSprites.NewSprite(transform, "GridV", ProceduralSprites.Square, gridColor, 1f, -18); + g.transform.localPosition = new Vector3(x, 0f, 0f); + g.transform.localScale = new Vector3(0.04f, h, 1f); + } + for (float y = -h * 0.5f + gridStep; y < h * 0.5f - 0.01f; y += gridStep) + { + var g = ProceduralSprites.NewSprite(transform, "GridH", ProceduralSprites.Square, gridColor, 1f, -18); + g.transform.localPosition = new Vector3(0f, y, 0f); + g.transform.localScale = new Vector3(w, 0.04f, 1f); + } + + var mid = ProceduralSprites.NewSprite(transform, "MidLine", ProceduralSprites.Square, new Color(1f, 1f, 1f, 0.14f), 1f, -17); + mid.transform.localScale = new Vector3(w, 0.06f, 1f); // 테두리 var borderColor = new Color(0.85f, 0.75f, 0.45f); - const float t = 0.12f; + const float t = 0.16f; MakeBorder("BorderTop", new Vector2(0f, h * 0.5f + t * 0.5f), new Vector2(w + t * 2f, t), borderColor); MakeBorder("BorderBottom", new Vector2(0f, -h * 0.5f - t * 0.5f), new Vector2(w + t * 2f, t), borderColor); MakeBorder("BorderLeft", new Vector2(-w * 0.5f - t * 0.5f, 0f), new Vector2(t, h), borderColor); @@ -52,7 +68,7 @@ namespace TKS void MakeBorder(string name, Vector2 pos, Vector2 size, Color color) { - var sr = ProceduralSprites.NewSprite(transform, name, ProceduralSprites.Square, color, 1f, -17); + var sr = ProceduralSprites.NewSprite(transform, name, ProceduralSprites.Square, color, 1f, -16); sr.transform.localPosition = pos; sr.transform.localScale = new Vector3(size.x, size.y, 1f); } diff --git a/Assets/Scripts/Runtime/CameraController.cs b/Assets/Scripts/Runtime/CameraController.cs new file mode 100644 index 00000000..acde62b4 --- /dev/null +++ b/Assets/Scripts/Runtime/CameraController.cs @@ -0,0 +1,137 @@ +using UnityEngine; + +namespace TKS +{ + /// + /// 넓은 전장용 카메라: 화면보다 큰 전장을 빈 곳 드래그로 스크롤 · 휠로 줌 · 행동 중인 유닛 자동 추적 · 전장 밖으로 못 나가게 고정. + /// + public class CameraController : MonoBehaviour + { + public Board Board; + public float ViewHeight = 18f; + public float MinViewHeight = 10f; + public float MaxViewHeight = 36f; + public float Margin = 1f; + public float FollowSpeed = 7f; + + Camera cam; + Vector2? focusTarget; + Transform followTarget; + + public Camera Cam + { + get + { + if (cam == null) cam = GetComponent(); + return cam; + } + } + + public void Init(Board board, float viewHeight, float minViewHeight, float maxViewHeight) + { + Board = board; + MinViewHeight = Mathf.Max(1f, minViewHeight); + MaxViewHeight = Mathf.Max(MinViewHeight, maxViewHeight); + ViewHeight = Mathf.Clamp(viewHeight, MinViewHeight, MaxViewHeight); + Cam.orthographicSize = ViewHeight * 0.5f; + SetPosition(transform.position); + } + + /// 화면 1 픽셀이 월드 몇 단위인가(드래그 스크롤 환산). + public float WorldUnitsPerPixel + { + get { return Cam.orthographicSize * 2f / Mathf.Max(1, Cam.pixelHeight); } + } + + public Rect ViewRect + { + get + { + float halfH = Cam.orthographicSize; + float halfW = halfH * Cam.aspect; + Vector2 c = transform.position; + return new Rect(c.x - halfW, c.y - halfH, halfW * 2f, halfH * 2f); + } + } + + public void PanBy(Vector2 worldDelta) + { + focusTarget = null; + followTarget = null; + SetPosition((Vector2)transform.position + worldDelta); + } + + public void FocusOn(Vector2 worldPos) + { + followTarget = null; + focusTarget = worldPos; + } + + public void SnapTo(Vector2 worldPos) + { + followTarget = null; + focusTarget = null; + SetPosition(worldPos); + } + + public void Follow(Transform t) + { + focusTarget = null; + followTarget = t; + } + + public void StopFollow() + { + followTarget = null; + focusTarget = null; + } + + public void Zoom(float factor) + { + SetViewHeight(ViewHeight * factor); + } + + public void SetViewHeight(float height) + { + ViewHeight = Mathf.Clamp(height, MinViewHeight, MaxViewHeight); + Cam.orthographicSize = ViewHeight * 0.5f; + SetPosition(transform.position); + } + + void LateUpdate() + { + Vector2? goal = null; + if (followTarget != null && followTarget.gameObject.activeInHierarchy) goal = (Vector2)followTarget.position; + else if (focusTarget.HasValue) goal = focusTarget; + if (!goal.HasValue) return; + + Vector2 target = Clamp(goal.Value); + Vector2 cur = transform.position; + float k = 1f - Mathf.Exp(-FollowSpeed * Time.deltaTime); + Vector2 next = Vector2.Lerp(cur, target, k); + SetPosition(next); + if (focusTarget.HasValue && Vector2.Distance(next, target) < 0.02f) focusTarget = null; + } + + void SetPosition(Vector2 p) + { + p = Clamp(p); + transform.position = new Vector3(p.x, p.y, -10f); + } + + public Vector2 Clamp(Vector2 p) + { + if (Board == null) return p; + float halfH = Cam.orthographicSize; + float halfW = halfH * Cam.aspect; + var r = Board.Rect; + float minX = r.xMin - Margin + halfW; + float maxX = r.xMax + Margin - halfW; + float minY = r.yMin - Margin + halfH; + float maxY = r.yMax + Margin - halfH; + p.x = minX > maxX ? 0f : Mathf.Clamp(p.x, minX, maxX); + p.y = minY > maxY ? 0f : Mathf.Clamp(p.y, minY, maxY); + return p; + } + } +} diff --git a/Assets/Scripts/Runtime/CameraController.cs.meta b/Assets/Scripts/Runtime/CameraController.cs.meta new file mode 100644 index 00000000..39896c52 --- /dev/null +++ b/Assets/Scripts/Runtime/CameraController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e83ae9280a929b244832db516173bdf0 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/EnemyAI.cs b/Assets/Scripts/Runtime/EnemyAI.cs index 77e3b58e..671b345d 100644 --- a/Assets/Scripts/Runtime/EnemyAI.cs +++ b/Assets/Scripts/Runtime/EnemyAI.cs @@ -3,10 +3,10 @@ using UnityEngine; namespace TKS { - /// 적군 슬링샷 결정: 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. + /// 적군 슬링샷 결정: 교전 범위 안의 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. 범위 밖이면 대기(null). public static class EnemyAI { - public static Vector2 Decide(Unit e, IReadOnlyList targets, float accuracy, System.Random rng, Board board) + public static Vector2? Decide(Unit e, IReadOnlyList targets, float accuracy, float aggroRange, System.Random rng, Board board) { Unit nearest = null; float best = float.MaxValue; @@ -21,7 +21,8 @@ namespace TKS nearest = t; } } - if (nearest == null) return e.Position; + if (nearest == null) return null; + if (best > aggroRange) return null; // 넓은 전장: 교전 범위 밖이면 자리 지킴 Vector2 toT = nearest.Position - e.Position; float dist = toT.magnitude; diff --git a/Assets/Scripts/Runtime/GameBootstrap.cs b/Assets/Scripts/Runtime/GameBootstrap.cs index 41701009..c2c603a0 100644 --- a/Assets/Scripts/Runtime/GameBootstrap.cs +++ b/Assets/Scripts/Runtime/GameBootstrap.cs @@ -10,6 +10,7 @@ namespace TKS public int StartStageIndex = 0; public BattleManager Battle { get; private set; } + public CameraController CameraCtrl { get; private set; } void Awake() { @@ -33,54 +34,47 @@ namespace TKS cam.transform.position = new Vector3(0f, 0f, -10f); cam.nearClipPlane = 0.1f; cam.farClipPlane = 100f; - var fit = cam.GetComponent(); - if (fit == null) fit = cam.gameObject.AddComponent(); var board = new GameObject("Board").AddComponent(); board.Build(Config.boardSize); - fit.Board = board; - fit.Fit(); + + CameraCtrl = cam.GetComponent(); + if (CameraCtrl == null) CameraCtrl = cam.gameObject.AddComponent(); + CameraCtrl.Init(board, Config.cameraViewHeight, Config.cameraMinViewHeight, Config.cameraMaxViewHeight); Battle = new GameObject("BattleManager").AddComponent(); Battle.Configure(Config, board); var hud = new GameObject("HUD").AddComponent(); - hud.Build(Battle, cam); + hud.Build(Battle, cam, CameraCtrl); Battle.Hud = hud; var input = new GameObject("SlingshotInput").AddComponent(); - input.Init(Battle, cam, hud); + input.Init(Battle, cam, hud, CameraCtrl); Battle.Sling = input; + // 카메라 연동: 행동 중인 유닛 추적 · 아군 턴 시작 시 아군 쪽으로 + var camCtrl = CameraCtrl; + var battle = Battle; + battle.UnitActing += u => camCtrl.Follow(u.transform); + battle.PhaseChanged += phase => + { + if (phase == BattlePhase.PlayerTurn) camCtrl.FocusOn(PlayerFocusPoint(battle)); + else if (phase == BattlePhase.StageClear || phase == BattlePhase.Defeat) camCtrl.StopFollow(); + }; + battle.StageStarted += () => camCtrl.SnapTo(PlayerFocusPoint(battle)); + if (AutoStart) Battle.StartStage(StartStageIndex); } - } - /// 화면 비율이 달라도 전장 전체가 보이도록 직교 카메라 크기를 맞춘다. - public class CameraFitter : MonoBehaviour - { - public Board Board; - public float Margin = 0.5f; - Camera cam; - - void Awake() + /// 아군 무게중심에서 적 쪽으로 조금 올린 지점. + static Vector2 PlayerFocusPoint(BattleManager battle) { - cam = GetComponent(); - } - - void LateUpdate() - { - Fit(); - } - - public void Fit() - { - if (cam == null) cam = GetComponent(); - if (Board == null || cam == null) return; - float halfW = Board.Size.x * 0.5f + Margin; - float halfH = Board.Size.y * 0.5f + Margin + 1.2f; // 위아래 HUD 여유 - float aspect = Mathf.Max(0.1f, cam.aspect); - cam.orthographicSize = Mathf.Max(halfH, halfW / aspect); + var players = battle.AliveUnits(Faction.Player); + if (players.Count == 0) return Vector2.zero; + Vector2 sum = Vector2.zero; + foreach (var p in players) sum += p.Position; + return sum / players.Count + new Vector2(0f, battle.Config.cameraViewHeight * 0.2f); } } } diff --git a/Assets/Scripts/Runtime/SlingshotInput.cs b/Assets/Scripts/Runtime/SlingshotInput.cs index 26ff8930..b7d30c12 100644 --- a/Assets/Scripts/Runtime/SlingshotInput.cs +++ b/Assets/Scripts/Runtime/SlingshotInput.cs @@ -5,6 +5,7 @@ namespace TKS { /// /// 슬링샷 조작: 아군 유닛을 잡고(누름) 뒤로 당긴 뒤(드래그) 놓으면(드롭) 반대 방향으로 이동·공격. + /// 빈 곳 드래그 = 전장 스크롤 · 휠 = 줌 · 미니맵 탭 = 그 지점으로 이동. /// 버튼 없음. 결과 화면에서는 탭으로 계속. 추가 이동만 남았을 때 빈 곳 탭 = 건너뛰기. /// public class SlingshotInput : MonoBehaviour @@ -12,12 +13,15 @@ namespace TKS BattleManager battle; Camera cam; BattleHUD hud; + CameraController camCtrl; Unit selected; bool dragging; + bool panning; bool emptyPress; bool previewValid; - Vector2 pressWorld; + Vector2 lastScreen; + float panPixels; Vector2 currentTarget; LineRenderer aimLine; @@ -30,11 +34,17 @@ namespace TKS get { return selected; } } - public void Init(BattleManager b, Camera c, BattleHUD h) + float TapPixels + { + get { return Mathf.Max(8f, Screen.height * 0.012f); } + } + + public void Init(BattleManager b, Camera c, BattleHUD h, CameraController cc) { battle = b; cam = c; hud = h; + camCtrl = cc; aimLine = MakeLine("AimLine", 0.08f, new Color(1f, 1f, 1f, 0.9f), 10); pullLine = MakeLine("PullLine", 0.035f, new Color(1f, 1f, 1f, 0.35f), 9); targetRing = ProceduralSprites.NewSprite(transform, "TargetRing", ProceduralSprites.Ring, Color.white, 0.7f, 11); @@ -59,9 +69,9 @@ namespace TKS return lr; } - Vector2 PointerWorld() + Vector2 ScreenToWorld(Vector2 screen) { - Vector3 p = Input.mousePosition; + Vector3 p = screen; p.z = -cam.transform.position.z; return cam.ScreenToWorldPoint(p); } @@ -76,16 +86,36 @@ namespace TKS return; } - if (Input.GetMouseButtonDown(0)) OnPress(PointerWorld()); - else if (dragging && Input.GetMouseButton(0)) OnDrag(PointerWorld()); - if (Input.GetMouseButtonUp(0)) OnRelease(PointerWorld()); + Vector2 screen = Input.mousePosition; + if (Input.GetMouseButtonDown(0)) OnPress(screen); + else if (Input.GetMouseButton(0)) + { + if (dragging) OnDrag(ScreenToWorld(screen)); + else if (panning) OnPan(screen); + } + if (Input.GetMouseButtonUp(0)) OnRelease(screen); + + float wheel = Input.mouseScrollDelta.y; + if (Mathf.Abs(wheel) > 0.01f && camCtrl != null) camCtrl.Zoom(1f - wheel * 0.1f); } - void OnPress(Vector2 w) + void OnPress(Vector2 screen) { - pressWorld = w; + lastScreen = screen; + panPixels = 0f; + dragging = false; + panning = false; emptyPress = false; previewValid = false; + + Vector2 mapWorld; + if (hud != null && camCtrl != null && hud.TryMinimapTap(screen, out mapWorld)) + { + camCtrl.FocusOn(mapWorld); + return; + } + + Vector2 w = ScreenToWorld(screen); var u = battle.UnitNear(w, Faction.Player); if (u == null) u = battle.UnitNear(w, null); Select(u); @@ -93,10 +123,21 @@ namespace TKS { dragging = true; } - else + else if (u == null) { - dragging = false; - emptyPress = (u == null); + panning = true; + emptyPress = true; + } + } + + void OnPan(Vector2 screen) + { + Vector2 d = screen - lastScreen; + lastScreen = screen; + panPixels += d.magnitude; + if (panPixels > TapPixels && camCtrl != null) + { + camCtrl.PanBy(-d * camCtrl.WorldUnitsPerPixel); } } @@ -118,7 +159,7 @@ namespace TKS ShowPreview(selected, currentTarget, w); } - void OnRelease(Vector2 w) + void OnRelease(Vector2 screen) { if (dragging && selected != null && previewValid && battle.CanAct(selected)) { @@ -128,7 +169,7 @@ namespace TKS Select(null); battle.RequestPlayerAction(u, t); } - else if (emptyPress && Vector2.Distance(w, pressWorld) < 0.3f) + else if (emptyPress && panPixels <= TapPixels) { battle.OnEmptyTap(); } @@ -137,6 +178,7 @@ namespace TKS HidePreview(); } dragging = false; + panning = false; emptyPress = false; previewValid = false; } diff --git a/Assets/Scripts/Runtime/UnitView.cs b/Assets/Scripts/Runtime/UnitView.cs index b8d9932a..2e31a1f3 100644 --- a/Assets/Scripts/Runtime/UnitView.cs +++ b/Assets/Scripts/Runtime/UnitView.cs @@ -31,10 +31,11 @@ namespace TKS baseColor = u.Faction == Faction.Player ? PlayerColor : EnemyColor; float d = u.Radius * 2f; - overwatchArea = ProceduralSprites.NewSprite(transform, "OverwatchArea", ProceduralSprites.Circle, new Color(0.3f, 0.65f, 1f, 0.16f), u.AttackRange * 2f, -6); - overwatchArea.enabled = false; + // 창병 대기 반경 표시(창병만 · 발동 중에만 오브젝트 통째로 켬) + overwatchArea = ProceduralSprites.NewSprite(transform, "OverwatchArea", ProceduralSprites.Circle, new Color(0.3f, 0.65f, 1f, 0.16f), u.Type == UnitType.Spear ? u.AttackRange * 2f : 0f, -6); var owRing = ProceduralSprites.NewSprite(overwatchArea.transform, "Ring", ProceduralSprites.Ring, new Color(0.5f, 0.8f, 1f, 0.6f), 1f, -5); owRing.transform.localScale = Vector3.one; + overwatchArea.gameObject.SetActive(false); if (u.IsBoss) { @@ -79,7 +80,8 @@ namespace TKS if (flash == null) body.color = c; mark.color = dim ? new Color(1f, 1f, 1f, 0.6f) : Color.white; - overwatchArea.enabled = unit.Overwatch; + bool ow = unit.Overwatch && unit.Type == UnitType.Spear && unit.IsAlive; + if (overwatchArea.gameObject.activeSelf != ow) overwatchArea.gameObject.SetActive(ow); badge.gameObject.SetActive(unit.BonusMoveAvailable && unit.IsAlive); } @@ -123,7 +125,7 @@ namespace TKS { selectRing.enabled = false; highlightRing.enabled = false; - overwatchArea.enabled = false; + overwatchArea.gameObject.SetActive(false); float t = 0f; const float dur = 0.45f; Vector3 s0 = transform.localScale; diff --git a/Assets/Tests/PlayMode/BattleFlowTests.cs b/Assets/Tests/PlayMode/BattleFlowTests.cs index ff835f11..67485f90 100644 --- a/Assets/Tests/PlayMode/BattleFlowTests.cs +++ b/Assets/Tests/PlayMode/BattleFlowTests.cs @@ -220,5 +220,35 @@ namespace TKS.Tests Assert.AreEqual(BattlePhase.StageClear, bm.Phase); Assert.IsFalse(e.IsAlive); } + + [UnityTest] + public IEnumerator EnemyAI_HoldsOutsideAggroRange_ActsInside() + { + yield return Fresh(); + var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -13f), "아군"); + var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 10f), "먼적"); + var near = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -5f), "가까운적"); + var players = bm.AliveUnits(Faction.Player); + Assert.IsFalse(EnemyAI.Decide(far, players, 1f, 11f, bm.Rng, bm.Board).HasValue, "교전 범위 밖 → 대기"); + Assert.IsTrue(EnemyAI.Decide(near, players, 1f, 11f, bm.Rng, bm.Board).HasValue, "교전 범위 안 → 행동"); + Assert.IsTrue(p.IsAlive); + } + + [UnityTest] + public IEnumerator Camera_BoardWiderThanView_ClampsAndPans() + { + var cc = Camera.main.GetComponent(); + Assert.IsNotNull(cc, "넓은 전장 카메라 컨트롤러"); + Assert.Greater(bm.Config.boardSize.y, bm.Config.cameraViewHeight, "전장이 화면보다 넓어야 스크롤이 의미 있다"); + var r = bm.Board.Rect; + cc.SnapTo(new Vector2(1000f, 1000f)); + var v = cc.ViewRect; + Assert.LessOrEqual(v.yMax, r.yMax + cc.Margin + 0.01f, "전장 위쪽 밖으로 못 나감"); + Assert.LessOrEqual(v.xMax, r.xMax + cc.Margin + 0.01f + Mathf.Max(0f, v.width - r.width), "전장 오른쪽 밖으로 못 나감"); + cc.SnapTo(Vector2.zero); + cc.PanBy(new Vector2(0f, 2f)); + Assert.AreEqual(2f, cc.transform.position.y, 0.01f, "빈 곳 드래그 스크롤"); + yield return null; + } } } diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index ad1b676c..5f5b881a 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -86,7 +86,7 @@ PlayerSettings: androidApplicationEntry: 2 defaultIsNativeResolution: 1 macRetinaSupport: 1 - runInBackground: 0 + runInBackground: 1 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 Force IOS Speakers When Recording: 0 diff --git a/README.md b/README.md index 599e5412..ebe588d3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Unity 6000.3.23f1 · 2D · 도형 플레이스홀더(아트 리소스는 별도 - 아군 유닛을 누른 채 **뒤로 당겼다 놓으면** 반대 방향으로 날아간다(슬링샷). 하얀 선 = 날아갈 경로, 끝의 링 = 최종 목표점. 링이 주황색이면 목표점이 적 위에 있다(명중). - 이동과 공격은 하나의 조작이다. 경로 위에 적이 있으면 병종 규칙대로 공격한다. 주황 링이 켜진 적 = 이번 조작으로 공격하게 될 적. +- 전장(20×32)은 화면보다 넓다. **빈 곳을 드래그하면 스크롤**, 마우스 휠 = 줌, 오른쪽 위 **미니맵을 탭하면 그 지점으로 이동**. 행동 중인 유닛은 카메라가 따라간다. +- 적은 교전 범위(스테이지별 11~12) 안에 아군이 들어와야 움직인다. 멀리 있는 부대는 자리를 지키므로 부대 단위로 각개격파할 수 있다. - 아군 전원이 행동하면 자동으로 적군 턴이 진행된다. 보병 추가 이동만 남았을 때 빈 곳을 탭하면 건너뛴다. - 승리/패배 화면은 탭으로 다음 전투·재도전. - 스킬 아이콘은 자리만(초기 단계 미구현). @@ -35,7 +37,7 @@ Unity 6000.3.23f1 · 2D · 도형 플레이스홀더(아트 리소스는 별도 ## CLI ``` -# 컴파일 + 설정 자산·씬 생성 +# 컴파일 + 설정 자산·씬 생성 (BuildFresh = 기본값 재시드까지) Unity.exe -batchmode -nographics -projectPath -executeMethod TKS.EditorTools.ProjectSetup.Build -quit -logFile # 플레이모드 테스트 @@ -47,6 +49,6 @@ Unity.exe -projectPath -executeMethod TKS.EditorTools.ProjectSetup.AutoPl ## 구조 -- `Assets/Scripts/Runtime/` — `GameBootstrap`(씬 구성) · `BattleManager`(턴·승패) · `ActionResolver`(병종 규칙) · `Combat`(피해 공식·기하 질의) · `SlingshotInput`(조작) · `EnemyAI` · `BattleHUD` · `Unit`/`UnitView` · `Board` +- `Assets/Scripts/Runtime/` — `GameBootstrap`(씬 구성) · `BattleManager`(턴·승패) · `ActionResolver`(병종 규칙) · `Combat`(피해 공식·기하 질의) · `SlingshotInput`(조작·스크롤·줌) · `CameraController`(넓은 전장 카메라) · `EnemyAI`(교전 범위) · `BattleHUD`(HUD·미니맵) · `Unit`/`UnitView` · `Board` - `Assets/Scripts/Editor/` — `ProjectSetup`(자동 구성) · `BalanceDefaults`(최초 시드) - `Assets/Tests/PlayMode/` — 병종 규칙·턴 흐름·승리 판정 테스트