Manager, controller, handler 마다
어떤 기능이 구현되어야하는지 책임 분리에 대해서 헷갈리고 어려웠음.
따라서 일반적으로 어떤식으로 분리되어야하는지 큰 틀에서 먼저 짚고넘어갔다.
1. Manager
게임 전반의 흐름(상태전환), 씬관리, 시스템간 조율을 담당하며 싱글턴패턴이 적용된다.
GameManager, UIManager, AudioManager, SaveManager
2. Controller
게임 오브젝트의 직접적인 동작 제어 (입력, 이동, 추적 등) 주로 MonoBehaviour
PlayerController, CameraController, EnemyController
3. Handler
특정 도메인(애니메이션, 스탯, 리소스 등) 만 책임지고 처리
작업 단위별로 컴포넌트화.
AnimationHandler, StatHandler, ResourceHandler
4. Service
순수 로직, 유틸(데이터 저장, 네트워크 호출 등)
MonoBehaviour가 아닐 수도있다.
Saveservice, InputSerivce, AudioService
5. UI
UI 이벤트 바인딩, 화면 요소 업데이트
UIState에 따라 활성화/ 비활성화 됌.
HomeUI, GameUI, GameOverUI
-단일 책임원칙(SRP): 한클래스는 한가지 일만한다.
-느슨한 결합: 가능한 인터페이스, 이벤트로 커뮤니케이션.
- 확장성 : 새기능 추가시 기존 코드는 최소한으로 변경한다.
맵을 돌아다니다가 특정 존에 들어가면 미니게임을하게되고, 스코어를 받아서 다시
나와야한다.
미니게임을 끝내고 완료 콜백을 받는것에대해서도 공부해봤다.
델리케이트를 쓰는것이 굉장히 생소한데, 해당 기능을 사용해보기위해 기본적인 개념부터
잡았다. 과거 코루틴에 대해서 간단하게 다룬적이있는데.. 좀더 개념적으로 확실하게 짚고
넘어간다.
먼저 비동기와 동기에 대해서 공부했다.
동기(Synchronous)란
한줄 한줄 차례대로 실행하는것을 말한다.
앞 명령이 완전히 끝나야 다음 명령을 실행한다.
void DoThings()
{
LoadScene( "MiniGame");
ReturnToMap();
}
위 코드는 로드씬이 끝나야만, returntomap 메서드를 실행한다.
따라서 완전히 로드된 뒤에 map으로 돌아오게된다.
비동기(Asynchornous)란
명령을 시작만 하고 즉시 리턴(return)한다.
실제 작업(씬로드, 네트워크 요청 등)은 백그라운드에서 계속 진행되고있음
완료 시점을 알 수 있도록 콜백이나 이벤트, 코루틴의 yield 등을 사용한다.
AsnycOperation op = SceneManager.LoadSceneAsnyc("MiniGame"); // LoadSceneAsync는 작업을 시작만 하고 즉시 반환한다.
Debug.Log(" 이 로그는 로드가 끝나기 전이라도 바로 찍힌다!");
ReturnToMap(); // 씬 로드가 끝나기전에 호출되어 버린다.
여기선
LoadSceneAsync 자체가 바로 AsyncOperation 객체를 리턴하고
그 뒤에 바로 ReturnToMap() 이 실행되기 때문에
실제 씬 전환은 아직 진행중인 상태에서 복귀 처리가 일어나 버린다.
여기서 끝난시점에 다음 처리를 붙이는 두가지 방법이 있는데.
바로 콜백방식과 코루틴 방식이다.
먼저 콜백방식이다.
void LoadMiniGameWithCallBack(Action onComplete)
{
AsyncOperation op = SceneManager.LoadSceneAsnyc("MiniGame");
op.completed += _ =>
{
onComplete?.Invoke();
}
}
void StartMiniGame()
{
LoadMiniGameWithCallback( () =>
{
ReturnToMap();
} );
}
op.completed 이벤트에 끝난 뒤 실행할 함수를 등록해두면
Unity가 "씬 로드 완료" 를 감지했을때 그 함수를 불러준다.
그다음은 코루틴 방식(IEnumerator + yield return)
IEnumerator LoadAndReturnRoutine()
{
yield return SceneManager.LoadSceneAsync("MiniGame");
ReturnToMap();
}
호출부에서
StartCoroutine(LoadAndReturnRoutine());
yield return은 뒤의 코드로 작성된 작업이 끝날때까지 기다렸다가 다음 줄로 넘어가게 해준다.
우리의 Unity 씬전환의 경우 (특히 LoadSceneAsync)은
백그라운드에서 진행되는 비동기 작업이다.
왜 비동기로 처리했냐면,
1. 프레임드롭 방지
만약 씬로드를 동기로한다면, 그동안 게임은 멈춰버린다.
동기 로드는 모든 에셋(모델, 텍스쳐, 사운드 등)을 한꺼번에 디스크나 패키지에 불러와
메모리에 올려야해서 로드 중 프레임도 훅 하고 떨어진다.
반면 비동기 로드는 여러프레임에 걸쳐 조금씩 씬을 채워가기 때문에 화면이 멈추지않고
부드럽게 이어진다.
2. 메모리 디스크 I/O 분산처리
대용량 에셋을 순식간에 불러오면 디스크 I/O 가 몰려서 전체 시스템 성능에 부담을준다.
Unity는 내부적으로 여러단계(에셋 해제, 압축해제, GPU 업로드 등) 로 나눠서 처리하고
각 단계가 끝날때마다 AsyncOperation.completed 이벤트를 통해 "이 단계 끝났다" 라고 알려준다.
3. 유연한 로딩 UX 구현
비동기 로드 덕분에 로딩바나 로딩화면 애니메이션등을 동시에 보여줄 수 있다.
진행상태(90퍼 로드 완료) 등도 AsnycOperation.Progress 로 끊임없이 읽어올 수 있다.
실제 동작흐름을 보자면
1.SceneMangaer.LoadSceneAsync("MiniGame") 호출
이 순간 Unity는 씬 로딩작업을 시작만하고 즉시 AsyncOperation 객체를 리턴해준다.
아직 에셋불러오기, 설치작업등은 백그라운드에서 진행중이다라고보면된다.
2.AsyncOperation.completed 또는 yield return 대기
콜백 : op.competed += YourHandler;
코루틴 : yield return SceneManager.LoadSceneAsync...;
이중 하나를 사용해 실제로 다 끝났을때 후속 코드를 실행하도록 한다.
3. 완료 신호를 받음
모든 필수 에셋이 메모리에 올라오고 씬이 화면에 나타날 준비가 끝난 시점에야
우리가 등록한 콜백이나, 코루틴의 다음 줄 코드가 실행된다.
몇가지 덧붙여서 알고가자.
Unity에서는 실시간 플레이 중 씬 전환시에는 거의 대부분
LoadSceneAsync를 쓴다.
SceneManager에서는
LoadScene(동기), LoadSceneAsync(비동기) 이렇게 존재하는데
LoadScene의 경우
호출 즉시 씬로드될때까지 프레임 몸추며
대용량씬에서는 심한끊김이 발생한다.
로딩 진행도 조회도 불가하며, 완료시점알림은 로드 즉시이다.
LoadSceneAsync 의경우
백그라운드에서 조금씩 로딩, 메인 스레드는 계속 돌아가며
부드러운 전환가능하고
로딩 진행도 조회도 AsyncOperation.progress로 0~0.9 구간(완료 직전)까지 확인 가능
완료 시점알림은
AsyncOperation.completed 이벤트(또는 while(!op.isDone) yield return null)
동기로드의 경우
간단한 툴이나 에디터 전용작업
씬크기가 작아 끊김이 체감안될때
로딩화면이 필요없는 테스트용일때 주로 쓰고
비동기로드는
게임플레이 중 씬전환하거나
로딩/프로그레스바를 보여주고싶고
메인 맵 위에 UI 씬이나 파티클 씬을 덧붙여 로드할때 주로 쓰인다.
여기서 비동기로드씨 Unity 엔진이 제공하는 AsyncOperation 클래스가 관여하는데
비동기 작업의 진행상태, 완료이벤트를 관리한다.
해당 예제에서
completed는
public class AsyncOperation : YieldInstruction
{
// … 생략 …
// 씬 로드, 에셋 로드 등 비동기 작업이 끝나면 호출되는 델리게이트 목록
public event Action<AsyncOperation> completed;
// … 생략 …
}
위와 같은 클래스에 정의되어있다.
해당 completed 이벤트에 함수를 구독해두면 엔진이 비동기로드가 100% 끝났다
라고 판단하는 순간에 등록된 모든 콜백을 호출해준다.
람다식 문법도 한번더 짚어넘어가자.
_=> {...} 식으로 사용되었는데
람다식 기본문법은
(파라미터 목록) => { 실행할 코드} 인데,
completed 이벤트의 시그니처는 위의 내장된 클래스 처럼 Action<AsyncOperation>이므로
파라미터 하나(타입은 AsyncOperation)를 받으며
반환없는 델리게이트들이 등록 될 수 있어야한다.
완전 명시적으로 쓴다면
op.completed += (AsyncOperation ao) => { onComplete?.Invoke();}
이런식으로 쓸테지만, (ao는 엔진이 넘겨주는 AsyncOperation 인스턴스)
우리는 ao를 코드안에서 쓰지않는다. 따라서 파라미터는 있지만 이름붙여쓸필요는없기
때문에 관례로 _ (언더바)를 붙인다.
파라미터를 완전히 생략하는 안받는 람다식은
() = > { ... } 인데
위에 보면 파라미터에 AsnyncOperaction 타입을 받긴해야하기때문에 시그니처 불일치로
오류가 일어날 수 있기때문에 반드시 한개를 받고 안쓴다는 취지에서 _ 를 사용한것이다.
참고로 이벤트 구독은 +=, -=로 구독, 해지하지만
Action 델리게이트 변수는 함수 또는, 익명함수를 =로 대입, += 구독추가, -= 리스트에서 제거 모두
가능하다.
public class Loader
{
// 델리게이트 변수
public Action onComplete;
public void Load()
{
// ... 로드 시작
// 끝난 뒤
onComplete?.Invoke();
}
}
void Example()
{
var loader = new Loader();
// 1) 기본 대입
loader.onComplete = () => Debug.Log("A만 실행");
// 2) 추가 구독
loader.onComplete += () => Debug.Log("B도 실행");
// 3) 제거
loader.onComplete -= () => Debug.Log("A만 실행"); // (람다는 익명이라 실제론 제거 안 됨 주의)
loader.Load();
// 출력:
// A만 실행
// B도 실행
}
다시 AsyncOperation 클래스로 넘어가
해당 클래스에서 만들어진 객체는
로드 진행상태( .progress), 완료여부(.isDone) , 완료시점에 알림을 받을수있는 이벤트
(.completed)를 제공한다. (.allowSceneActivation) 이라고 프로그레스가
0.9(90%) 지점까지 로드된후 자동전환할지 여부를 의미하는것도 있다.
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
해당 AsyncOperation 타입 op 인스턴스에 SceneManager.LoadSceneAsync(sceneName)
즉, 백그라운드에서 sceneName을 로드중인 작업을 참조하게 한다. 라는 뜻으로
여기서 SceneManager.LoadSceneAsync( ...) 는 팩토리 메서드라는 역할로 불리운다.
내부에서 var op = new AsyncOperation(); 도 함께 하기때문에 객체를 생성과 동시에
할당한다고보면된다.
LoadSceneAsync의 반환형이 AsyncOperation이므로 우리가 받는
참조 op 의 타입도 AsnycOperation여야만 하다.
그다음에 op 객체는 여러가지 기능들중에 completed를 써서
op.completed += (AsyncOperation operation) =>
{
onComplete?.Invoke();
};
이벤트를 호출하는 작업을 할 수 있다.
여기서 op는 핸들이라고 할수있는데
“핸들(handle)” 이란, 엔진 내부에서 진행 중인 작업(씬 로드) 을 제어·조회할 수 있는 참조(reference) 를 말합니다.
op 는 그 핸들이고, 덕분에 우리는:
op.progress 로 현재 로드 퍼센트를 읽고
op.completed 이벤트에 콜백을 붙여서 “끝난 순간 알려 달라” 고 요청할 수 있고
op.allowSceneActivation 으로 “언제 실제 씬 전환을 허용할지” 조절할 수 있다.
-----------------
강의를 보며 폴더를 작성하는데
컨트롤러들은 Entity에 담는다
Entity가 뭐지?
Unity 프로젝트에서 Assets/Scripts 같은 상위폴더아래에
기능별, 도메인별로 서브폴더를 만드는것은 관례인데,
Entity폴더는 보통 게임세계의 개별객체(Entity) - 플레이어, 적, Npc, 투사체 등
을 관리하는 스크립트를 모아두는 곳으로 쓴다.
-------------
[ ] 이걸로 serializedField를 쓰기도하고, 이것저것 쓰이는데 뭔 문법이지?
c# & Unity 에서 [ ... ] 로 둘러싼 문법은 속성(attribute)이라고 부른다.
[RequireCompoenet( ...)] 는 Unity 가 제공하는 빌트인 attribute 중하나로
이스크립트를 붙인 GameObject에
반드시 지정한 컴포넌트(예를들면 Rigidbody2D)가 함께 붙어있도록 자동으로 보장해준다.
왜 쓰느냐면,
1. 실수방지
개발자가 실수로 Rigidbody2D를 안붙이고 PlayerController만 달아도,
Unity가 에디터 단계에서 자동으로 Rigidbody2D 컴포넌트를 추가해준다.
2. 코드안정성
GetComponent<Rigidbody2D>() 호출시 null 체크 없이 바로 사용할 수 있어,
런타임에러(NullReferenceException)를 예방합니다.
Attribute(속성)에는 아래와 같은것들이 있다.
[RequireCompoenet(typeof(x))]
해당 스크립트가 붙을때 자동으로 컴포넌트 x를 추가하도록 Unity에 지시
[SerializeField]
private 필드라도 Inspector에 노출해서 에디터에서 값 설정 가능하도록 해줌.
[Range(min, max)]
숫자 필드를 슬라이더 형태로 Inspector에 보여줌
[ExecuteInEditMode]
게임이 실행중이 아니어도 에디터 상에서 스크립트의 update()등을 호출하도록함.
[Header("...")]
인스펙터 창에서 필드 그룹에 제목(헤더)를 붙여준다.
[Tooltip("...")]
마우스 오버시 튤팁을 표시해, 무엇을 뜻하는 필드인지 알려준다.
------------------
MonoBehaviour 상속 클래스에서
Update() 함수와 FixedUpdate()함수의 차이는 뭘까?
Update 함수는
매 렌더링 프레임마다 가변으로 호출되며
시간보정값은 Time.deltaTime (지난 프레임과의 실제 경과시간)
이다
주요 용도로는
입력처리(키보드,마우스 등)
애니메이션, UI 업데이트 등 물리연산이 아닌 일반로직을 쓰며
프레임이 느려지면 호출빈도가 떨어진다.
FixedUpdate함수는
고정된 시간간격(기본 0.02초 = 50hz)로 호출되며
시간보정값은 Time.fixedDeltaTime (설정된 고정 시간간격)
이다.
주요 용도로는
물리연산(physics)처리
Rigidbody 속도 조작, 물리 힘 적용등이며
프레임이 느려져도 일정하게 호출되므로 물리시뮬레이션이 일정하게 유지된다.
따라서,,
Update함수는
Input.GetAxis, Input.GetKeyDown 처럼 유저 입력 처리에 쓰이거나
카메라 스무스한 이동(물리와 무관한 보관)
UI나 애니메이션 상태갱신에 쓰이거나
프레임 당 가변적인 로직에 쓰이고
FixedUpdate 함수의 경우엔
Rigidbody 관련 움직임 예를들어
rigidbody.AddForce(), rigidbody.velocity = ... 등에 쓰이거나
물리충돌검사, 중력보정등 Physics엔진과 연계된 로직에 쓰일수있다.
일정한 간격으로 계산되어야 물리시뮬레이션이 매끄럽게 돌아간다.
참고로 AddForce()는
물리엔진(Rigidbody)에 힘(force)을 가하는 메서드로
Rigidbody에 순간적으로 또는 지속적으로 힘을 줘서 속도가 바뀌도록한다.
3D에는 Rigidbody.AddForce(Vector3 force, ForceMode mode = Forcemode.Force)
2D에는 Rigidbody2D.AddForce(Vector2 force, ForceMode2D mode = ForceMode2D.Force)
가있다.
force : 물체에 가할 힘의 크기와 방향을 나타내는 벡터
mode : 힘을 어떻게 가할지 결정
ForceMode.Force 매 프레임마다 지속적으로 힘을가함 (질량 영향받음)
ForceMode.Impulse 순간적인 충격력을 가함(질량을 무시한 즉각적인 속도변화)
3D에만 추가적으로 Acceleration/ VelocityChange 모드도 있다..
언제쓰냐면, 물리적으로 자연스러운 움직임이 필요할때 쓰인다.
캐릭터가 밀리는 효과, 중력이외의 외부충격
총알이나 충돌시 튕겨나가는 반동(knockback)
풍선, 자동차 바퀴 구동력 등....
FixedUpdate 안에서 호출한다.
AddForce는 velocity와 다르게
다른물체와 충돌,마찰,질량 영향을 자연스럽게 처리해주고
힘을 계속가하면 점진적 가속/감속가능하도록 관성,가속도 표현이가능하며
반동 또는 튕김효과도 쉽게 구현이 가능하다.
---------------
점수 저장을 위한,
PlayerPrefs 공부
PlayerPrefs는 Unity가 제공하는 아주 간단한 키-값 기반 저장소 API로
게임 세션을 넘어(에디터 재시작이나 빌드된 게임을 껐다 켜도) 데이터를 영구저장
할수있게 해준다.
예를들면, 사용자의 설정(볼륨, 그래픽옵션) 이나 얻은 점수중 Highscore 처럼
한번 얻은 값들을 다음 플레이 때도 불러오고 싶을때 쓰는 기능이다.
PlayerPrefs의 지원 타입과 주요 메서드
int - PlayerPrefs.setInt(key, 값) 으로 저장, PlayerPrefs.GetInt(key, 기본값)으로 불러오기
float - 전부 같고, setFloat, GetFloat
string - 전부 같고, setString, GetString
key : 나중에 이 값을 불러올때 사용할 문자열 식별자
기본값: 저장된 값이 없을때 대신 돌아오는값 : 0 , 0,0f , " " 등
부가메서드
PlayerPrefs.Haskey(key) 해당키가 저장되어있는지 여부조회
PlayerPrefs.DeleteKey(key) 특정키만 삭제
PlayerPrefs.DeleteAll() 모든 PlayerPrefs 데이터삭제
PlayerPrefs.Save() 즉시 디스크(플레이어 레지스트리등)에 기록
Save()를 호출하지않아도 대부분 플랫폼에서 자동저장된다.
중요한순간(애플리케이션 종료직전등) 에는 Save()를 명시적으로 불러주는게 안전하다.
대량데이터는 부적합하다.
작은설정(점수,옵션)용으로
대량 데이터는 JSON 파일 저장 등의 방식을 이용하자.
사용에 있어서는 예를들면
Awake()나 Start()에 해당 코드를 쓰게된다면,,
float vol = PlayerPrefs.GetFloat("MusicVolume", 0.5f);
// 아직 "MusicVolume" 키가 없으므로, vol에는 0.5가 들어간다.
아직 해당 string 키값이 없으므로, 0.5f로 vol변수에 할당한것일뿐이다.
그러다가,
PlayerPrefs.SetFloat("MusicVolume", 0.8f);
PlayerPrefs.Save();
이렇게하면 실제로 MusicVolume 키가 생성되며 0.8f 이 디시크또는 레지스트리에 기록된다.
다음실행(키가 이미 있으므로)
Awake()에서 똑같이 해당 라인이 실행되겠지만..
float vol = PlayerPrefs.GetFloat("MusicVolume", 0.5f);
// "MusicVolume" 키가 있으므로, 0.8이 뽑혀 나온다.
// 기본값 0.5는 무시된다.
정리하자면, GetX(key, default) 의 default 값은
키가 없으면 -> default를 반환
키가 있다면 -> Setx 에서 저장한 값 반환을 한다.
저장여부를 코드로 확인하고싶다면.
if(!Player.Prefs.HasKey("MusicVolume"))
{ Debug.Log("첫실행이거나 저장된 값이 없습니다!"); }
else
{ Debug.Log("저장된 볼륨:", PlayerPrefs.GetFloat("MusicVolume"));}
이런식으로 HasKey로 기본값, 저장된값의
존재여부를 bool 타입으로 확인할 수 있다.
--------------
UI 전환시
책임분리 로직
서브클래스로 실제 구현체가될 UI들의 BaseUI 클래스를 작성할때,
public abstract class BaseUI : MonoBehaviour
{
protected UIManager uiManager;
public virtual void Init(UIManager umgr)
{
uiManager = umgr;
}
protected abstract UIState GetUIState();
public void SetActive(UIState state)
{
gameObject.SetActive(state == GetUIState());
}
}
GetUIState() 와 SetActive(UIState state)를 분리해 놓은 이유가 궁금했다.
우선 UIManager 클래스에서 실제로 현재 상태를 전환만 담당할것이고
BaseUI는 그 전환을 비교 하고 키고 끄는 메서드만 제공할것이다.
BaseUI를 상속한 각 UI 들은 스스로 내가 어떤 상태에 대응하는 UI인지 알도록 만든다.
여기서 virtual void Init으로 각 UI 객체들이 공통으로 uiManager에
UIManager 의존성을 주입하도록 할것이고..
protected abstract UIstate GetUIState(); 메서드를 이용해
파생클래스(MapUI, MiniGameUI)가 나는 UIState.Map 이야, (UIState 는 enum 이다)
UIState.MiniGame이야 라고 알려주는 역할을함.
public void SetActive(UIState state);
state (현재 화면상태)와 GetUIState() (이패널의 고유상태)를 비교해서
같으면
gameObject.SetActive(true) 키고
다르면
gameObject.SetActive(false) 끄는 역할을한다.
UIManager는 지금 화면이 A일때 모든 패널에게 알려줘서 알아서 켜고 끄게 하면되고
개별 패널은 내 상태가 뭐고 켜질 조건은 뭐다. 라고 알면된다.
한마디로 UIManager는 전부 setActive 해놓은상태로 추가만 해놓으면되니,
계속 UI가 생길때마다 추가하면되고,,
개별 UI 패널들이 GetUIState 를 구현하면서 return UIState.Map 이런식으로 스스로를
결정해주니까..
BaseUI 타입 UI 패널들은 SetActive 메서드를 통해 알아서 매개변수로 받은 state와 GetUIState로 리턴한 state를
비교체크하고 맞다면 켜고 틀리면 끄는방식으로 연동된다.
이를 통해 책임 분리가된다.
------------
추상클래스는 객체 생성이안되므로
해당 클래스 스크립트는 드래그앤 드롭으로 Manager의 컴포넌트에 넣을수도없다
구현체인 서브클래스를 만들어서 컴포넌트에 드래그앤드롭할 게임오브젝트에 붙여준뒤에
드롭하면 넣어진다.
-----------
FindObjectOfType<T>() 에대해서 알아보자.
Unity에서 정의된 제네릭 정적메서드다.
호츨 시점에 메모리상에 로드된 모든 게임 오브젝트를 뒤져서
이 오브젝트에 T 타입의 컴포넌트가 붙어있나? 검사한다.
찾으면 그 컴포넌트 인스턴스를 반환한다.
못찾으면 null을 반환한다.
비용이 꽤 큰 호출이어서 Update나 FixedUpdate 같은 반복메서드안에선 지양한다.
대신 한번에 호출해서 변수에 캐싱하거나
UIManager같은 매니저급을 싱글턴 패턴으로 만들어두거나
[SerializeField] private UIManager uiManager; 처럼 에디터에서 직접 참조를
할당해 두면 반복 검색 없이 빠르게 접근 가능하다.
-------------
BaseController와 PlayerController 상속관계에서의 사용에 대한 일부 의문점
우선 MonoBehaviour 를 상속받는 BaseController가 있고
public class BaseController : MonoBehaviour
{
protected Rigidbody2D _rigidbody;
[SerializeField] private SpriteRenderer characterRenderer;
[SerializeField] private Transform weaponPivot;
protected Vector2 movementDirection = Vector2.zero;
public Vector2 MovementDirection{get{return movementDirection;}}
protected Vector2 lookDirection = Vector2.zero;
public Vector2 LookDirection{get{return lookDirection;}}
private Vector2 knockback = Vector2.zero;
private float knockbackDuration = 0.0f;
protected virtual void Awake()
{
_rigidbody = GetComponent<Rigidbody2D>();
}
protected virtual void Start()
{
}
protected virtual void Update()
{
HandleAction();
Rotate(lookDirection);
}
protected virtual void FixedUpdate()
{
Movment(movementDirection);
if(knockbackDuration > 0.0f)
{
knockbackDuration -= Time.fixedDeltaTime;
}
}
protected virtual void HandleAction()
{
}
private void Movment(Vector2 direction)
{
direction = direction * 5;
if(knockbackDuration > 0.0f)
{
direction *= 0.2f;
direction += knockback;
}
_rigidbody.velocity = direction;
}
private void Rotate(Vector2 direction)
{
float rotZ = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
bool isLeft = Mathf.Abs(rotZ) > 90f;
characterRenderer.flipX = isLeft;
if (weaponPivot != null)
{
weaponPivot.rotation = Quaternion.Euler(0, 0, rotZ);
}
}
public void ApplyKnockback(Transform other, float power, float duration)
{
knockbackDuration = duration;
knockback = -(other.position - transform.position).normalized * power;
}
}
해당 BaseController를 상속받는 PlayerController 가 있다.
public class PlayerController : BaseController
{
private Camera camera;
protected override void Start()
{
base.Start();
camera = Camera.main;
}
protected override void HandleAction()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
movementDirection = new Vector2(horizontal, vertical).normalized;
Vector2 mousePosition = Input.mousePosition;
Vector2 worldPos = camera.ScreenToWorldPoint(mousePosition);
lookDirection = (worldPos - (Vector2)transform.position);
if (lookDirection.magnitude < .9f)
{
lookDirection = Vector2.zero;
}
else
{
lookDirection = lookDirection.normalized;
}
}
}
이거 두개에 대해서 좀 헷갈리는부분이있었다.
우선
protected virtual void Update()
{
HandleAction();
Rotate(lookDirection);
}
여기서 보면 BaseController에서 HandleAction();을 계속 업데이트하는데
플레이어 오브젝트에 붙인 스크립트는 PlayerController.cs 야
그리고 실제로
protected virtual void HandleAction()
{
}
라고해서 BaseController에서는 아무것도 구현안되고 정의만되어있어
protected override void HandleAction()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
movementDirection = new Vector2(horizontal, vertical).normalized;
Vector2 mousePosition = Input.mousePosition;
Vector2 worldPos = camera.ScreenToWorldPoint(mousePosition);
lookDirection = (worldPos - (Vector2)transform.position);
if (lookDirection.magnitude < .9f)
{
lookDirection = Vector2.zero;
}
else
{
lookDirection = lookDirection.normalized;
}
if (Input.GetKeyDown(KeyCode.E))
{
GameManager.Instance.OnInterect();
}
}
이거는 PlayerController에서 구현된 내용이야.
내가 궁금한점은 PlayerController가 BaseController를 상속받긴했지만,
Update를 따로 메서드를 실행하진않았지
그런데도 BaseController의 Update가 실행이 되는 이유가뭘까.
상속받으면 다되는건가?아니면 뭔가 설정을 해준건가?
였다..
이것에대해서 공부해보니
따로 PlayerController에서 BaseController에 있는 메서드들을 쓸때,
다시 오버라이드하고싶다면 접근제한자를 맞춰줘야하지만, 그냥 받아서 작동만할거라면,
따로 정의나 구현하지않아도 된다는 부분
그냥 사용만 할 때
BaseController에 정의된 Update(), FixedUpdate(), HandleAction() 같은 가상(virtual) 메서드는, 파생 클래스에서 아무것도 안 해도 매 프레임 Unity가 그대로 호출해 주고, 내부에서 HandleAction()→PlayerController.HandleAction() 흐름도 자동으로 타게 됩니다.
→ 따로 정의·구현할 필요 없음
심지어 virtual이 없어도 BaseController에서 정의된 Update()는 파생클래스에 그대로 상속되어
Unity가 매 프레임 호출한다. 다만 Virtual이 없다면 오버라이드를 할수없다.
동작을 바꾸고 싶을 때(오버라이드)
PlayerController에서 예를 들어 protected override void Update()나 protected override void HandleAction()처럼 오버라이드하려면,
원본(BaseController)의 선언부 접근 제한자(public/protected)와 똑같이 맞춰야
override 키워드가 정상 컴파일되고 기대하는 대로 작동함.
현시점에서 오버라이드한 내용을 제외하고는
예를들어 update()의 경우엔 virtual, protected 접근제한자를 쓸필요가 없어보인다.
그럼에도 썼던 이유는 확장성에서 있다.
protected(접근 제한자)의 역할
public void Update()로 두면 다른 클래스에서 someController.Update()처럼 직접 호출할 수 있게된다..
private void Update()로 두면 오직 그 클래스 내부에서만 보이고 접근이 안된다.
protected void Update()로 두면 파생 클래스(자식 클래스) 안에서는 보이지만, 그 외부에서는 숨길 수 있다.
→ 즉, “이 메서드는 외부에서 쓰려고 만든 게 아니라, 오직 상속받은 클래스가 필요할 때만 확장하라는 뜻”의 의도를 가진다.
virtual(가상 메서드)의 역할
virtual을 붙여야 파생 클래스에서 override 키워드를 써서 메서드를 재정의할 수 있다.
만약 “자식 클래스가 Update 로직을 한 번 더 바꿀 수도 있겠다”는 확장 포인트를 열어두고 싶다면 virtual이 필요하다.
반대로, 절대 재정의하지 않을 거라면 virtual을 생략해도 된다..
-----------------
애니메이션에서 컨디션 설정을 위해 bool 값으로 IsMove를 Animator 에서 만들었다고 쳐보자.
문자열 파라미터 이름 (" IsMove") 로 매번 문자열 비교를하면 성능이 떨어지므로
StringToHash 라는 걸로 그 이름을 정수형 해시값으로 바꿔줘
내부에서 빠르게 비교가능하게 하는 방법이있다.
메모리, cpu 절약에 도움도된다.
private static readonly int IsMoving = Animator.StringToHash("IsMove");
readonly는 초기화 이후 변경불가한 것을 설정할때 쓴다.
해시값은 게임이 시작할때 한번만 정해두고 끝까지 재사용하기때문에 해당선언을하였다.
public void Move(Vector2 obj)
{
animator.SetBool(IsMoving, obj.magnitude > .5f);
}
이만큼 움직이고있다, 라고하는 벡터(방향,크기)정보를 넘겨줄때 쓰는 메서드로
obj.magnitude로 해당 벡터의 길이(크기)를 구하는 프로퍼티다.
스칼라를 구한다.
> .5f
임계값으로 벡터길이가 0.5이상이면 충분히 움직이고 있다고 판단하고
IsMoving 이라는 아까 만들어둔 int 타입 키를 true로 만들어주는 조건이다.
0.5미만이면 false를 반환한다.
눈치챘겠지만,
SetBool(in id, bool value) 는
애니메이터 파라미터 중 bool 타입인 IsMove를
해시값 IsMoving으로 찾아서
true/false를 설정해주는 함수다.
animator.SetBool("IsMove", true); 이렇게 문자열버전으로도 쓸수는있다.
다만 내부에서 StringToHash를 호출하므로 런타임마다 해시 계산을해야하고
비교도 문자열이라 조금 느리다.
두번째인자인 value에는 오직 bool 타입만 받는다.
true/false를 리턴하는 모든 표현식을 넣을 수 있다.
그예시로는 아래와같다.
// 1) 직접 변수 사용
bool isGrounded = player.IsGrounded;
animator.SetBool(IsGroundedHash, isGrounded);
// 2) 비교 연산식
animator.SetBool(IsFallingHash, rb.velocity.y < 0f);
// 3) 메서드 호출 결과
animator.SetBool(CanShootHash, weapon.CanFire());
// 4) 논리 연산 조합
animator.SetBool(IsRunningAndGroundedHash,
Input.GetKey(KeyCode.LeftShift) && isGrounded);
// 5) 상수
animator.SetBool(IsDeadHash, true);
---------------
수학공부- 작성은 생략
예체능 출신인지라 벡터, 정규화, 벡터의 뺄셈, 덧셈에 대한 개념을 잘몰랐다. 알았더라도
까먹은 부분이라서 처음부터 다시 공부했다.
삼각함수, 삼각비에 대해서 다시 공부했다.
단위원을 기준으로 라디안, 도 에 대해서 다시 공부했다.
이를 통해 기본적인 캐릭터 회전, 속도,방향 에대한 이론을 간단하게나마 이해할 수 있게되었다...
-------------
카메라가 캐릭터를 따라오도록하려면...
우선 Camera에 접근할수있게
awake에서
cam= GetComponent<Camera>(); 로 불러와준뒤
cam. 으로 접근해서 아래와 같은 정보들을 할당해줘야한다.
카메라가 캐릭터를 중심점으로 잡고 따라간다고 상상해보면
쉽게 보자면,
카메라의 포지션값을 캐릭터의 포지션값과 일치시키면된다.
해당 Camera 스크립트를 붙인 MainCamera 오브젝트의
transform.position = target.position // 여기서 target은 Player를 인스펙터창에서 붙여놓은상태.
그렇지만, 해당 과정에서 player를 따라가다가 절대 맵 바깥으로 나가지않도록 위치를 제한하고싶다.
이때 Mathf.Clamp 라는 코드를 사용할수있다.
Mathf.Clamp(a, min , max) 는 ark min보다 작으면 min을, max보다 크면 max를
둘사이면 그냥 a를 반환해주는 함수다.
맵사이즈가 정해져있고, 캐릭터를 따라가다보면 카메라가 캐릭터가 맵의 가장자리에 다다를수록
맵 바깥의 빈화면을 보여주게 될것이다. 그러지않고,, 맵경계안에서만 놀도록하기위해서
해당 함수를 사용하면 좋다.
이를 위해서,, 캐릭터가 움직일때 카메라가 조금이라도 맵 경계를 벗어나지않게하려면
카메라 중심이 카메라 x축과 y축 을 기준으로 카메라 실제 뷰포트 절반너비, 높이보다
벗어나게되면 뷰포트가 맵 바깥 공간을 보여주게된다는 것을 알 수있다.
앞서 할당한 cam.orthographicsize 로 접근하게되면 해당 카메라의 높이의 절반길이를
반환하는데, 즉 카메라의 전체 높이가 6이라면 3을 반환하는것이다.
cam.aspect로 접근하면 해당 카메라 뷰포트의 가로/세로 비율을 반환하는데
가로: 세로 비율이 2: 1이면 2를 반환하는것이다.
전체높이 x aspect 하게되면 즉 전체가로길이를 반환할텐데, 여기서 필요한것은
절반의 너비, 그리고 절반의 높이를 찾아야한다.
따라서,
float hw = cam.orthographicSize * cam.aspect; 로 절반의 너비를 찾아오고
float hh = cam.orthographicSize; 로 절반의 높이를 찾아온뒤,
변수 백터 두개를 선언해준다.
public Vector2 minPos, maxPos;
각각 뷰포트의 왼쪽하단 시작점 x,y 그리고 오른쪽 상단 끝점 x,y 에 대한 포지션값을 담을 예정이다.
float x = Mathf.Clamp(target.positon.x , minPos.x + hw, maxPos.x - hw);
float y = Mathf.Clamp(target.position.y, minPos.y + hh, maxPos.y - hh);
이런식으로 한다면, x 포지션은 플레이어의 x 포지션을 그대로 할당받다가,
왼쪽 시작점지점에서 절반가로길이보다 작아진다면 그대로 minPos.x+hw 를 x 포지션으로
반환할것이다 maxPos에 대한 내용도 마찬가지의 얘기고
y포지션 전체에 대한 내용도 마찬가지의 내용이다.
이 x,y가 카메라의 좌표가 될것이다.
여기에 이제 부드럽게 따라오게하는 보간을 덧붙이면 카메라가 즉발적으로 캐릭터를 쫓는게아니라
부드럽게 쫓아가게도 할 수 있다.
앞서 정한 x,y를 사용해서 아래와같이 위치를 잡아준,
Vector3 desiredPos = new Vector3(x, y, transform.position.z); 이것이 기본값이라면
부드럽게 쫓아오게하는 정수형태 혹은 실수형태의 smoothSpeed 변수 초기화를 해주고 (5f 정도)
transform.position = Vector3.Lerp(transform.position, desiredPos, Time.deltaTime * smoothSpeed);
으로 해준다면 해당 카메라 오브젝트의 위치는 부드럽게 보간된다.
Vector3.Lerp(a,b,t)는 벡터 a와 b사이를 t비율만큼 보간해서 그 결과 벡터를 반환해주는 함수다.
t=0 이면 시작점만 반환(a)
t=1 이면 즉시 도약해서 끝점만반환(b)
0<t <1 일때 중간위치를 반환한다.
Time.deltaTime(지난 프레임의 시간) * 5f 계수만큼 보간해준다.
대충 프레임간격이 0.02초 정도(fixedUpdate 기준) 라면
0.02 *5 = 0.1 정도니까
현재위치에서 목표위치로 10%만큼만 이동시키는셈이고
이 과정을 매프레임 반복하면 카메라가 부드럽게 목표를 향해 점점 가까워진다.
매프레임 반환할때마다 b(끝점) 까지의 거리는 점점 줄어든다는 것을 알아두자.
시작점이 transform.postion인 이유는 바로 카메라가 있는위치를 말하고있기때문이고
한번 보간될때마다 조금씩 desiredPos를 따라 재설정될테니,, 결국 부드럽게 따라가도록
설정된다는 것을 알 수 있다.
해당 minPos와 maxPos는 인스팩터창에서 임의로 수치를 써줄수도있지만
따로 스크립트를 짜줄수도있다.
뭐냐하면.. TileMap등으로 만든 Collision 타일들을 기준으로 삼을 수 있다는것이다.
따로 빈오브젝트를 하나 만들고 컨트롤러 급으로 네이밍해준뒤
해당 스크립트를 작성하여 붙였다.
public class CameraBoundsSetup_Collider : MonoBehaviour
{
public Collider2D levelBoundsCollider;
public CameraFollow camFollow;
void Start()
{
Bounds b = levelBoundsCollider.bounds;
camFollow.minPos = b.min;
camFollow.maxPos = b.max;
}
}
그리고 해당 오브젝트의 인스펙터창에
각각 만둘어두었던 collision 객체, 카메라 객체를 집어넣어주면
collision의 바운드를 가지고올수있다.
여기서 배운부분은
먼저 타일팔렛트로 만든 콜리전 객체에는
Tilemap Collider 2D 라는 컴포넌트를 붙여넣었으며
이 콜라이더 상위 클래스는
Collider2D라는 점이다. (BoxCollider2D로 했을때는 안됐다)
그래서 해당 COllider2D 타입으로 선언해놓은 levelBoundsCollider에 내가 만들었던
Collision 객체(맵의 경계) 를 비로서 인스팩터창에서 붙일 수 있었다.
levelBoundsCollider 변수에서 .bounds로 접근하여
Collider2D의 바운더리 크기를 가져올수있으며 (박스형태)
거기서 또 .min, .max로 왼쪽시작좌표, 오른쪽끝좌표를 가져와서 할당할 수 있다.
'Unity 개발 공부' 카테고리의 다른 글
| [내배캠] 본캠 26일차. 스킬 선택창, 스킬 UI 씬에 띄우기, 컨테이너 (1) | 2025.05.13 |
|---|---|
| [내배캠] 본캠 23일차. 싱글턴, 패턴매칭 (0) | 2025.05.09 |
| [내배캠] 본캠 18일차. 그래프 알고리즘, 다익스트라 (1) | 2025.04.30 |
| [내배캠] 본캠 17일차. 알고리즘, 정렬알고리즘, 선형 탐색알고리즘 (0) | 2025.04.29 |
| [내배캠] 본캠 15일차. 상속의 원리, 객체지향 (1) | 2025.04.25 |