2022. 2. 14. 19:52ㆍProjects/Amnyang
배경 오브젝트들 중에 플레이어와 상호작용이 가능한 오브젝트들이 몇 개 존재한다.
해당 오브젝트들과 상호작용을 하면, 오브젝트의 역할에 맡는 상호작용이 일어날 수 있게 만들어야 한다.
여러 종류가 있다.
- 단순 텍스트만 표시하여, 게임 스토리 진행에 대한 정보를 줄 수 있는 오브젝트
- 적의 추적을 피해, 숨을 수 있는 오브젝트 (ex. 우체통)
- 게임 진행을 하기 위해 얻어야 하는 오브젝트 (ex. 열쇠)
단순 구현에는 별로 어렵지 않은 작업들이다. 하지만, 나중에 새로운 종류의 오브젝트들이 추가될 수도 있을 것이고, 거기에 맞춰서 유동적으로 유지보수 및 추가가 용이하도록 만들어야 한다. 그래서 나는, 다음과 같은 구조를 생각했다.
- 플레이어(주인공)은 주변에 상호 작용 가능한 오브젝트가 있을 경우, 해당 오브젝트에게 상호작용을 요청한다.
- 상호작용 가능한 오브젝트는 플레이어에게 요청을 받으면, 자신의 고유 상호작용 기능을 수행해준다.
- 상호작용 가능한 오브젝트에 콜라이더와 작성한 상호작용 스크립트만 부착하면 작동되도록 만든 것
그리 어려운 구조는 아니다. 각 오브젝트별로 고유 상호작용 내용이 다르니, 이걸 어떻게 유동적으로 처리할까 고민을 많이 했다. 처음에는 상호작용 가능한 오브젝트의 공통적인 부분들을 묶어서, 상위 추상 클래스로 만든 다음, 각 오브젝트별로 스크립트를 만들고 상속받아 고유 내용을 처리하는 방식을 생각했다.
Ex) "InteractiveObject" 추상 클래스 만든 후, "Truck", "Mailbox" 등의 오브젝트에 대한 스크립트를 만들어 InteractiveObject를 상속받고, 구현부를 작성한다.
하지만, 해당 게임에서 상호 작용 가능한 오브젝트들이 그렇게 많지도 않을 뿐더러, 내부 내용도 거의 없어서 비효율적인 방법 같아 보였다. 그래서 다음으로 생각한 게 하나의 스크립트를 사용하되, "enum"을 사용하여 타입을 차별화하는 방식이었다.
괜찮은 방법인 것 같다. 당장 구현하러 가보자.
1. 주변에 상호작용 가능한 오브젝트가 있는지 판별하는 기능 만들기
우선 상호작용 오브젝트에 부착할 "InteractiveObject" 라는 스크립트를 만들었고, 주인공 캐릭터 수지를 제어할 스크립트(원래 SujiMoveController였는데 SujiController로 이름 변경)에 다음과 같이 레퍼런스 변수를 선언해줬다.
SujiController.cs
public float walkSpeed;
public float jumpPower;
public Transform suji;
public InteractiveObject InteractiveObject { set { _interactiveObject = value; } } // 추가
private InteractiveObject _interactiveObject; // 추가
...
상호작용 오브젝트에 콜라이더를 붙였고, 트리거(Trigger) 모드로 충돌이 되지 않도록 설정해줬다. 그리고 아까 만든 InteractiveObject 스크립트도 부착해줬다.
수지가 이 콜라이더 영역에 들어오면, 수지의 InteractiveObject 변수에 자기 자신 상호작용 오브젝트를 할당해주고, 벗어나면 null을 할당해주는 로직을 짰다.
InteractiveObject.cs
public ObjectType objectType;
private SujiController suji;
...
public void Interaction() // 수지가 요청한 상호작용에 대응해줄 함수
{
...
}
/* 수지의 Tag는 'Player'로 설정되어 있다. */
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
{
suji = collision.gameObject.GetComponent<SujiController>();
if (suji != null)
{
suji.InteractiveObject = this;
}
}
}
private void OnTriggerExit2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
{
if (suji != null)
{
suji.InteractiveObject = null;
suji = null;
}
}
}
이것으로 수지 주변에 상호 작용 가능한 오브젝트가 있는지 없는지 판별할 수 있는 로직이 완성되었다.
2. 상호작용 시스템 만들기
SujiController.cs에서 Update() 함수에 상호작용 키 입력을 받는 로직을 넣었다.
SujiController.cs
void Update()
{
walkDirection = Input.GetAxisRaw("Horizontal");
hasControl = !Mathf.Approximately(walkDirection, 0f);
isRunning = Input.GetButton("Run");
if(Input.GetButtonDown("Jump") && !isJumping)
{
Jump();
}
if (Input.GetButtonDown("Interaction")) // 추가 ('F' 키를 누를 시 True)
{
Interaction();
}
}
private void Interaction() // 추가
{
if (_interactiveObject == null)
return;
_interactiveObject.Interaction();
}
그리고 오브젝트 뒤에 숨는 함수(Hide)와 수지의 몸을 활성화/비활성화를 조절할 함수(ControlEnableMyBody)도 만들었다.
private bool isHiding;
public GameObject[] myBodies; // 각 신체 부위들이 들어가 있다.
public void Hide()
{
if (isJumping)
return;
if (!isHiding)
{
isHiding = true;
ControlEnableMyBody(false);
}
else
{
isHiding = false;
ControlEnableMyBody(true);
}
}
private void ControlEnableMyBody(bool enable)
{
float gravityScale = enable ? 3f : 0f;
if (!enable)
{
_collider.isTrigger = true;
_rigidBody.gravityScale = gravityScale;
}
foreach (var myBody in myBodies)
{
myBody.SetActive(enable);
}
if (enable)
{
_collider.isTrigger = false;
_rigidBody.gravityScale = gravityScale;
}
}
InteractiveObject.cs
public void Interaction()
{
if (objectType == ObjectType.eCanHideObject)
{
suji.Hide();
}
else if(objectType == ObjectType.eTextPrintObject)
{
/// TextManager에게 이 상호작용 오브젝트의 "objectTag"를 매개변수로 해서 전달
/// ex) TextManager.ShowDialog(string objectTag);
/// TextManager에서는 이 태그를 key로 하여, 거기에 대응되는 텍스트(value)를 가져오면 됨
/// 싱글톤(Singleton) 패턴으로 구현하면 좋을 듯하다.
}
else if(objectType == ObjectType.eItemObject)
{
/// GameDataManager 오브젝트를 만들어서, 이 오브젝트의 아이템 데이터를 전달하여 저장
/// 역시, 싱글톤 패턴으로 구현하면 좋을 듯
/// ex) 열쇠를 먹기 전 : key = 0 / 열쇠 먹은 후 : key = 1
}
}
이렇게 하면, "숨을 수 있는 오브젝트"에 숨기/나오기 로직이 완성된 것이다.
/* 나머지 오브젝트들은 차례대로 만들면 된다. */
3. 구현하면서 겪었던 수많은 버그들
로직을 위와 같이 짤 수밖에 없었던 이유가 있었다. 다음은 수지 캐릭터의 계층구조다.
여기에서 다음과 같은 오류들을 겪었다.
- "Suji"를 활성화/비활성화 할 경우, 비활성화 상태일 때 키 입력이 되지 않음
- "SujiController" 스크립트가 이 오브젝트에 부착되어 있기 때문
- "Character"를 활성화/비활성화 할 경우, 애니메이터가 이 오브젝트에 부착되어 있기 때문에 문제가 발생한다.
- 다른 동작 중(ex. 달리기 또는 착지) 숨기를 하여 비활성화할 경우, 애니메이션이 재생되다가 중간에 비활성화가 되기 때문에 다시 활성화하면 이상한 자세로 되어 있다.
- "Suji"에 대한 애니메이션 클립을 땄었어야 했는데, "Character"에 대한 애니메이션 클립을 땄기 때문에 다시 만들어야 하는 상황이다.
- "Suji"에 콜라이더가 부착되어 있는데, 숨기를 해도 콜라이더를 비활성화해주지 않았기 때문에 숨어있는데도 불구하고 다른 오브젝트와 충돌이 일어난다.
- 콜라이더를 비활성화하니, 상호작용 가능한 오브젝트에서 탐지를 하지 못해 "isTrigger" 체크 및 해제로 충돌 처리를 했다.
- "isTrigger"가 체크될 시, 충돌이 일어나지 않으니 바닥을 뚫고 내려가는 상황이 발생했다. 그래서 숨었을 땐 중력을 0으로 주고, 나왔을 땐 다시 원래대로 줬다.
- 숨기를 했는데도, 이동 및 점프가 가능한 버그가 있었다. (즉각 수정해줌)
수많은 버그들이 나를 감싸주었다. 너무 따뜻하고 좋았다...ㅎㅎㅎ 그래도 다른 버그들은 대부분 해결했는데, 애니메이션 부분은 정말 생각지도 못했던 부분이었다. 애니메이션 클립을 다시 따기에는 시간이 많이 걸리고, 그래서 다음과 같은 꼼수를 썼다.
위와 같이 하면, 애니메이터는 활성화된 상태로 계속 있으니 중간에 중단되는 일이 없어지고 정상적인 자세로 있을 것이다. 그래서 위에서 작성한 코드가 나온 것이다.
처음에는 좀 더 개선적인 구조로 짜기 위해 리팩토링을 시작했다가, 예기치 못한 버그들을 차례대로 맞이했고 수정한다고 애를 먹었다. 그래도 다 해결할 수 있어서 다행이었다. 다음에는 오늘 해결한 버그 부분도 고려를 하고 게임을 만들 수 있도록 이렇게 기록을 남긴다.
✏️전체 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SujiController : MonoBehaviour
{
public float walkSpeed;
public float jumpPower;
public Transform suji;
public InteractiveObject InteractiveObject { set { _interactiveObject = value; } }
public bool IsHiding { get { return isHiding; } private set {} }
public GameObject[] myBodies;
private InteractiveObject _interactiveObject;
private Animator animator;
private Rigidbody2D _rigidBody;
private Collider2D _collider;
private WaitForSeconds landingDelay = new WaitForSeconds(0.5f);
private float walkDirection;
private float initScaleX;
private const float JUMP_CHARGING_DELAY = 0.55f;
private bool hasControl;
private bool isRunning;
private bool isJumping;
private bool isHiding;
void Start()
{
_rigidBody = GetComponent<Rigidbody2D>();
_collider = GetComponent<Collider2D>();
animator = GetComponentInChildren<Animator>();
initScaleX = suji.localScale.x;
}
void Update()
{
walkDirection = Input.GetAxisRaw("Horizontal");
hasControl = !Mathf.Approximately(walkDirection, 0f);
isRunning = Input.GetButton("Run");
if(Input.GetButtonDown("Jump") && !isJumping)
{
Jump();
}
if (Input.GetButtonDown("Interaction"))
{
Interaction();
}
}
void FixedUpdate()
{
TurnOtherSide();
Move();
}
public void Hide()
{
if (isJumping)
return;
if (!isHiding)
{
isHiding = true;
ControlEnableMyBody(false);
}
else
{
isHiding = false;
ControlEnableMyBody(true);
}
}
private void Interaction()
{
if (_interactiveObject == null)
return;
_interactiveObject.Interaction();
}
private void ControlEnableMyBody(bool enable)
{
float gravityScale = enable ? 3f : 0f;
if (!enable)
{
_collider.isTrigger = true; // enabled = false로 하면, 상호작용 오브젝트가 탐지를 못한다.
_rigidBody.gravityScale = gravityScale; // 콜라이더 충돌이 안 되게 설정했기 때문에, 바닥으로 떨어진다.
}
foreach (var myBody in myBodies)
{
myBody.SetActive(enable);
}
if (enable)
{
_collider.isTrigger = false;
_rigidBody.gravityScale = gravityScale;
}
}
private void Jump()
{
if (isHiding)
return;
isJumping = true;
if (!hasControl)
{
animator.SetBool("SargentJump", true);
Invoke("_Jump", JUMP_CHARGING_DELAY);
}
else
{
animator.SetBool("MoveJump", true);
_Jump();
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.layer == LayerMask.NameToLayer("Jumpable_Floor") && isJumping)
{
if (animator.GetBool("SargentJump"))
{
animator.SetTrigger("Landing");
animator.SetBool("SargentJump", false);
StartCoroutine(LandingDelay());
return;
}
animator.SetBool("MoveJump", false);
isJumping = false;
}
}
private void _Jump()
{
_rigidBody.AddForce(Vector2.up * jumpPower, ForceMode2D.Impulse);
}
IEnumerator LandingDelay()
{
yield return landingDelay;
isJumping = false;
}
private void Move()
{
/* 0f : Idle, 0.5f : Walk, 1f : Run */
if (isJumping)
return;
if (isHiding)
{
animator.SetFloat("Move", 0f);
_rigidBody.velocity = new Vector2(0f, _rigidBody.velocity.y);
return;
}
if (hasControl && isRunning)
{
animator.SetFloat("Move", 1f);
_rigidBody.velocity = new Vector2(walkDirection * walkSpeed * 2f, _rigidBody.velocity.y);
return;
}
float walkValue = (hasControl && !isRunning) ? 0.5f : 0f;
animator.SetFloat("Move", walkValue);
_rigidBody.velocity = new Vector2(walkDirection * walkSpeed, _rigidBody.velocity.y);
}
private void TurnOtherSide()
{
if (!hasControl || isJumping)
return;
var scaleX = suji.localScale.x;
if (Mathf.Approximately(walkDirection * scaleX, initScaleX))
return;
var scaleY = suji.localScale.y;
var scaleZ = suji.localScale.z;
suji.localScale = new Vector3(-scaleX, scaleY, scaleZ);
}
}
using System.Collections;
using System.Collections.Generic;
using ObjectState;
using UnityEngine;
public class InteractiveObject : MonoBehaviour
{
public ObjectType objectType;
private SujiController suji;
private string objectTag;
void Start()
{
objectTag = this.gameObject.tag; // 문자열은 레퍼런스 타입이기 때문에, 복사 시 가비지가 생성된다. 그러니 저장해놓고 쓰자.
}
public void Interaction()
{
if (objectType == ObjectType.eCanHideObject)
{
suji.Hide();
}
else if(objectType == ObjectType.eTextPrintObject)
{
/// TextManager에게 이 상호작용 오브젝트의 "objectTag"를 매개변수로 해서 전달
/// ex) TextManager.ShowDialog(string objectTag);
/// TextManager에서는 이 태그를 key로 하여, 거기에 대응되는 텍스트(value)를 가져오면 됨.
/// 싱글톤(Singleton) 패턴으로 구현하면 좋을 듯하다.
}
else if(objectType == ObjectType.eItemObject)
{
/// GameDataManager 오브젝트를 만들어서, 이 오브젝트의 아이템 데이터를 전달하여 저장
/// 역시, 싱글톤 패턴으로 구현하면 좋을 듯
/// ex) 열쇠를 먹기 전 : key = 0 / 열쇠 먹은 후 : key = 1
}
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
{
suji = collision.gameObject.GetComponent<SujiController>();
if (suji != null)
{
suji.InteractiveObject = this;
}
}
}
private void OnTriggerExit2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
{
if (suji != null)
{
suji.InteractiveObject = null;
suji = null;
}
}
}
}
namespace ObjectState
{
public enum ObjectType
{
eCanHideObject = 1,
eTextPrintObject = 2,
eItemObject = 3,
}
}
'Projects > Amnyang' 카테고리의 다른 글
[압량(Amnyang)] #12. 2D 캐릭터가 벽에 달라붙는 버그 수정 + 평상 밑에 숨기 구현하기 (0) | 2022.02.19 |
---|---|
[압량(Amnyang)] #11. 2D 공포 분위기 조성용 이벤트 만들기 (0) | 2022.02.15 |
[압량(Amnyang)] #9. 2D 게임 배경 간단히 배치 및 카메라 추적 이동 구현하기 (0) | 2022.02.13 |
[압량(Amnyang)] #8. 2D 주인공 이동 중 점프(Move Jump), 착지 후 경직 딜레이 구현하기 (0) | 2022.02.07 |
[압량(Amnyang)] #7. 2D 주인공 제자리 점프(Sargent Jump), 착지(Landing) 애니메이션 추가하기 (0) | 2022.02.06 |