넓은 전장(20×32)·스크롤 카메라·미니맵·적 교전 범위 — PD 추가 지시 「스크롤 가능한 비교적 넓은 전장」 · 빈 곳 드래그 스크롤·휠 줌·행동 유닛 추적·미니맵 탭 이동 · 적은 교전 범위 안에서만 행동 · 스테이지 4종 전위/중군/후군 재배치 · 창병 대기 링 상시 표시 결함 수정 · 자동 플레이 timeScale 가속 · 테스트 14종

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-04 04:29:16 +09:00
parent 5135d828e2
commit ceaec991dd
18 changed files with 689 additions and 244 deletions

View File

@ -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

View File

@ -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}

View File

@ -5,7 +5,7 @@ namespace TKS.EditorTools
{
/// <summary>
/// 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) · 적은 전위/중군/후군으로 흩어 배치.
/// </summary>
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<BalanceConfig.EnemySpawn>(enemies) };
return new BalanceConfig.StageDef { title = title, subtitle = subtitle, aiAccuracy = accuracy, aiAggroRange = aggro, enemies = new List<BalanceConfig.EnemySpawn>(enemies) };
}
}
}

View File

@ -33,6 +33,17 @@ namespace TKS.EditorTools
Debug.Log("[TKS] BalanceConfig 기본값 재시드");
}
/// <summary>CLI 용: 기본값 재시드 + 씬·설정 재생성(기존 자산 수치를 덮어쓴다).</summary>
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;
}

View File

@ -8,6 +8,7 @@ namespace TKS
/// <summary>
/// 자동 플레이 실측: 아군도 AI 로 조작해 몇 턴을 돌리며 스크린샷·로그를 남긴다.
/// 에디터에서 ProjectSetup.AutoPlayCapture 가 SessionState 로 출력 폴더를 넘기면 생성된다.
/// 비활성 에디터 창은 프레임이 느리므로 timeScale 을 올려 실시간 기준으로 빨리 끝낸다.
/// </summary>
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<CameraController>();
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

View File

@ -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<EnemySpawn> enemies = new List<EnemySpawn>();
}
@ -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)")]

View File

@ -1,13 +1,16 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace TKS
{
/// <summary>화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·스킬 아이콘(추후)·결과 오버레이.</summary>
/// <summary>화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 아이콘(추후)·결과 오버레이.</summary>
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<Unit, Image> minimapDots = new Dictionary<Unit, Image>();
public void Build(BattleManager b, Camera cam, CameraController cc)
{
battle = b;
uiCam = cam;
camCtrl = cc;
var canvas = gameObject.AddComponent<Canvas>();
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;
}
/// <summary>화면 점이 미니맵 안이면 해당 월드 좌표를 돌려준다.</summary>
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<Unit>();
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);
}
}
}
}

View File

@ -44,7 +44,14 @@ namespace TKS
get { return Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; }
}
/// <summary>상태가 바뀔 때(HUD 갱신용).</summary>
public event Action Changed;
/// <summary>단계가 바뀔 때(카메라 연동용).</summary>
public event Action<BattlePhase> PhaseChanged;
/// <summary>어떤 유닛이 행동을 시작할 때(카메라 추적용).</summary>
public event Action<Unit> UnitActing;
/// <summary>스테이지 배치가 끝났을 때.</summary>
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<Unit> 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);
}
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
@ -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();
}

View File

@ -7,7 +7,7 @@ namespace TKS
{
}
/// <summary>전장(바닥·테두리·4면 벽 콜라이더).</summary>
/// <summary>전장(바닥·격자·테두리·4면 벽 콜라이더). 화면보다 넓어 카메라로 스크롤한다.</summary>
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);
}

View File

@ -0,0 +1,137 @@
using UnityEngine;
namespace TKS
{
/// <summary>
/// 넓은 전장용 카메라: 화면보다 큰 전장을 빈 곳 드래그로 스크롤 · 휠로 줌 · 행동 중인 유닛 자동 추적 · 전장 밖으로 못 나가게 고정.
/// </summary>
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<Camera>();
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);
}
/// <summary>화면 1 픽셀이 월드 몇 단위인가(드래그 스크롤 환산).</summary>
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e83ae9280a929b244832db516173bdf0

View File

@ -3,10 +3,10 @@ using UnityEngine;
namespace TKS
{
/// <summary>적군 슬링샷 결정: 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다.</summary>
/// <summary>적군 슬링샷 결정: 교전 범위 안의 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. 범위 밖이면 대기(null).</summary>
public static class EnemyAI
{
public static Vector2 Decide(Unit e, IReadOnlyList<Unit> targets, float accuracy, System.Random rng, Board board)
public static Vector2? Decide(Unit e, IReadOnlyList<Unit> 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;

View File

@ -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<CameraFitter>();
if (fit == null) fit = cam.gameObject.AddComponent<CameraFitter>();
var board = new GameObject("Board").AddComponent<Board>();
board.Build(Config.boardSize);
fit.Board = board;
fit.Fit();
CameraCtrl = cam.GetComponent<CameraController>();
if (CameraCtrl == null) CameraCtrl = cam.gameObject.AddComponent<CameraController>();
CameraCtrl.Init(board, Config.cameraViewHeight, Config.cameraMinViewHeight, Config.cameraMaxViewHeight);
Battle = new GameObject("BattleManager").AddComponent<BattleManager>();
Battle.Configure(Config, board);
var hud = new GameObject("HUD").AddComponent<BattleHUD>();
hud.Build(Battle, cam);
hud.Build(Battle, cam, CameraCtrl);
Battle.Hud = hud;
var input = new GameObject("SlingshotInput").AddComponent<SlingshotInput>();
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);
}
}
/// <summary>화면 비율이 달라도 전장 전체가 보이도록 직교 카메라 크기를 맞춘다.</summary>
public class CameraFitter : MonoBehaviour
{
public Board Board;
public float Margin = 0.5f;
Camera cam;
void Awake()
/// <summary>아군 무게중심에서 적 쪽으로 조금 올린 지점.</summary>
static Vector2 PlayerFocusPoint(BattleManager battle)
{
cam = GetComponent<Camera>();
}
void LateUpdate()
{
Fit();
}
public void Fit()
{
if (cam == null) cam = GetComponent<Camera>();
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);
}
}
}

View File

@ -5,6 +5,7 @@ namespace TKS
{
/// <summary>
/// 슬링샷 조작: 아군 유닛을 잡고(누름) 뒤로 당긴 뒤(드래그) 놓으면(드롭) 반대 방향으로 이동·공격.
/// 빈 곳 드래그 = 전장 스크롤 · 휠 = 줌 · 미니맵 탭 = 그 지점으로 이동.
/// 버튼 없음. 결과 화면에서는 탭으로 계속. 추가 이동만 남았을 때 빈 곳 탭 = 건너뛰기.
/// </summary>
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;
}

View File

@ -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;

View File

@ -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<CameraController>();
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;
}
}
}

View File

@ -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

View File

@ -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 <proj> -executeMethod TKS.EditorTools.ProjectSetup.Build -quit -logFile <log>
# 플레이모드 테스트
@ -47,6 +49,6 @@ Unity.exe -projectPath <proj> -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/` — 병종 규칙·턴 흐름·승리 판정 테스트