241220

lililllilillll·2024년 12월 20일

개발 일지

목록 보기
26/350

✅ 오늘 한 일


  • Project Etude


📝 배운 것들


🏷️ 재정의(override) 가능한 메서드

  • 부모 클래스의 메서드가 virtual, abstract, 또는 override로 선언되지 않았다면, 자식 클래스에서 해당 메서드를 override로 재정의할 수 없습니다.
  • Unity의 Awake는 기본적으로 MonoBehaviour의 메서드로 virtual이 아니므로, 재정의하려면 부모 클래스에서 Awakevirtual로 명시해야 합니다.

🏷️ 접근 제한자와 상속

C#에서는 접근 제한자가 메서드의 상속 및 재정의 가능성에 영향을 줍니다:

  • protected 또는 public으로 선언된 메서드만 자식 클래스에서 접근할 수 있습니다.
  • 만약 메서드가 private로 선언되면 자식 클래스에서 접근하거나 재정의할 수 없습니다.

🏷️ protected 접근 제한자

protectedC#에서 클래스 멤버(필드, 메서드, 속성 등)에 사용할 수 있는 접근 제한자입니다.
이 제한자는 멤버에 대한 접근 권한을 다음과 같이 정의합니다:

  • 같은 클래스
  • 상속받은 클래스에서 접근할 수 있습니다.
  • 하지만 외부 클래스에서는 접근할 수 없습니다.

이 접근 방식은 상속 관계에서만 멤버를 공유하고, 외부에는 멤버를 숨길 수 있도록 설계되었습니다.

🏷️ virtual, override, abstract

public class ParentClass
{
    public virtual void Greet()
    {
        Debug.Log("Hello from ParentClass");
    }
}

public class ChildClass : ParentClass
{
    public override void Greet()
    {
        Debug.Log("Hello from ChildClass");
    }
}

virtual

virtual은 부모 클래스에서 정의된 메서드를 자식 클래스에서 선택적으로 재정의할 수 있도록 허용합니다.

  • 기본 구현이 부모 클래스에 제공됩니다.
  • 자식 클래스에서 재정의하지 않으면 부모 클래스의 구현이 사용됩니다.
  • 자식 클래스에서 재정의할 때는 반드시 override를 사용해야 합니다.

override

override는 부모 클래스에서 정의된 virtual 또는 abstract 메서드를 재정의할 때 사용하는 키워드입니다.

  • 부모 클래스의 메서드를 대체하거나 확장합니다.
  • 반드시 부모 클래스의 메서드와 동일한 이름, 반환 형식, 매개변수를 가져야 합니다.
public abstract class Animal
{
    public abstract void Speak(); // 구현 없음
}

public class Dog : Animal
{
    public override void Speak()
    {
        Debug.Log("Woof! Woof!");
    }
}

public class Cat : Animal
{
    public override void Speak()
    {
        Debug.Log("Meow!");
    }
}

abstract

abstract는 부모 클래스에서 메서드의 이름과 시그니처만 정의하고, 구현은 하지 않는 키워드입니다.

  • 반드시 추상 클래스(abstract class) 내부에서 사용됩니다.
  • 자식 클래스에서 반드시 해당 메서드를 구현(override)해야 합니다.
  • 추상 메서드는 기본 구현이 없으므로 자식 클래스에서 반드시 내용을 정의해야 합니다.


🎮 Project Etude


생각해보니 맵 로딩을 굳이 파일에서 가져올 이유가 없다.
일단 그냥 MapRoot 복붙해서 쓰자.
저장 불러오기 만든거 좀 아깝긴 한데, 어쩔 수 없다.
할 것도 많은데 메서드 쪼개갰다고 시간 보내고 싶진 않음.

이동 및 판정 구현

대각선을 같은 프레임 안에 누른다는 보장이 없음.
그렇게 되면 판정을 어떻게 해야할지에 대한 문제가 생김. 길을 따라 가지도 못하는 상황 발생할수도.
고로 이동과 판정을 합해서, 방향키를 둘 다 눌러서 맞음 판정을 받았을 때만 방향을 전환해줘야 함.

어떤 조건 a가 주어졌을 때
업데이트문을 돌면서 이동과 판정 로직을 실행하다가
어떤 조건 b가 주어지면
이동과 판정 로직을 끝내고 싶은데

스크립트 활성화와 비활성화를 사용하는게 이 상황에서의 best practice인가?

o1 mini 답변
스크립트 활성화 비활성화
if문을 이용한 플래그
switch문을 이용한 상태관리
coroutine
상황에 맞게 선택해라

if문이나 switch문은 의외로 성능 많이 안 잡아먹는다고 함

구현 draft

구현 난이도나 코드 길이로만 보면 스크립트 활성화 비활성화가 쉬워보이지만
시작은 게임 매니저나 플레이어, 끝은 종료 블럭에 의존하게 되는 점이 코드의 불필요한 복잡성을 늘릴 것 같음

상태관리가 코드적으로 더 예뻐보이긴 하지만
활성화/비활성화 이 2개의 상태밖에 없으므로
플래그로 간단하게 구현하면 될듯

if(첫 입력 플래그) 판정하고 이동시키기.
else 첫 입력 들어왔는지 확인. 맞는 입력인지 판정.

판정 및 이동 로직
블럭 판정을 통과했다면 이동 방향을 바꿔줌.
이동 방향대로 translate 이동

블럭 판정 로직
마지막 블럭에 도달했고 (최종적으로 눌러야 하는 인덱스라면) 마지막 블럭이 직접 플레이어 스크립트 비활성화

구현 계획 정리

판정 조건 : 블럭안에 있는 동안 요구하는 키를 전부 눌렀다
죽는 조건 : 쳐야하는 블럭을 치지 못하고 블럭 밖으로 나갔다.

플레이어는 game_ongoing 플래그로 update문에서 판정과 이동 로직 돌릴지 결정

플레이어 앞과 뒤에서 레이캐스트를 쏴서 블럭 인덱스 두개 확인,
둘 중 하나가 이번에 눌러야 하는 블록이고 inbox가 false면 inblock true 설정하고, keyinput을 초기화함.
둘 다 이번에 눌러야 하는 블록이 아닌데 inbox가 true였다면 게임 오버.

inbox가 true라면 판정 로직 호출
keyinput은 상하좌우 네 블럭의 입력 정보를 이진수로 비트마스킹 (맵 편집할 때 블럭에 해당 정보 기입해놓기)
상하좌우 누를 때마다 해당 자리수에 1 기입
if(keyinput | 블럭 정보 != 블럭 정보) 필요없는 방향키 눌렀으니 게임오버
if(keyinput == 블럭 정보)) 필요한 키 다 눌렀으니 판정 성공

판정 성공하면 친 블럭을 (콜라이더만큼은 반드시) 없애고, 플레이어 방향을 돌리고, 쳐야하는 블럭 인덱스를 +1, inblock false

왜 콜라이더 충돌이 아니라 raycast로 처리했는지?

OnCollisionStay()로 처리하면 굳이 raycast 앞뒤에 안 쏘고 inbox같은 변수 선언 안해도 알아서 상태관리가 됐겠지만, 그렇게 하려면 각 블럭에 OnCollisionStay()와 각종 로직을 달아야함. 연산이 좀 더 걸릴 것 같기도 하고, 플레이어에서 모든 로직을 처리하는게 코드가 좀 더 간단할 것 같았음. 퍼포먼스가 얼마나 차이나는지는 자세히 알아봐야겠지만, 일단 나중에...

첫 입력 문제

이제 구현만 하면 될거라고 생각했는데, 마저 생각할게 남아있었다.
맨 처음 시작할 때 첫 블럭에 해당하는 키를 누르면 그때부터 이동하기 시작하는 로직을 어떻게 구현할지 모르겠음.
그냥 상하좌우만 생각하면 안되고 대각선 블럭까지 지원해줘야 하니 더 복잡.

의외로 간단할수도. 그냥 기존 로직에서 이동에 game_ongoing 플래그 하나만 달아주면 될 것 같음.

앞과 뒤 문제

해당 오브젝트의 앞과 뒤에서 raycast를 쏴야 하는데,
앞과 뒤가 어딘지를 정하는게 문제.

o1에게 물어봤다.

유니티에서 특정 오브젝트의 앞과 뒤에서 raycast를 쏘고 싶다. 이때 오브젝트가 90도, 혹은 180도를 돌면 raycast를 쏘는 앞과 뒤의 위치도 같이 돌아야 한다. 해당 오브젝트는 update문에서 translate를 통해 이동하고 있다. 이때 raycast의 위치를 어떻게 지정해야 각도가 회전하여도 적절하게 앞과 뒤의 위치에서 raycast를 쏠 수 있는가? 예를 들어, 오브젝트를 180도 돌렸다면 앞과 뒤의 위치는 0도였을 때의 서로의 위치가 뒤바뀐 위치에서 raycast를 쏴야할 것이다.

Unity에서 오브젝트의 회전에 맞추어 Raycast를 앞/뒤 방향에서 정확히 쏘기 위해서는, 오브젝트의 로컬 좌표를 활용하여 위치를 지정하고, 회전 변화 시 그 로컬좌표를 월드좌표로 변환하는 방식이 일반적으로 사용됩니다. 다음과 같은 방법을 고려할 수 있습니다.

  1. 빈 GameObject를 자식 오브젝트로 두기:

    • 먼저, Raycast를 쏘고 싶은 "앞"과 "뒤" 위치에 빈 게임 오브젝트(Empty GameObject)를 오브젝트의 자식으로 배치합니다. 예를 들어 frontPointbackPoint라는 두 개의 Empty를 자식으로 두고, 오브젝트의 로컬 z축(+z)은 앞, -z축은 뒤라고 할 때, 각각 앞과 뒤에 해당하는 위치에 세팅합니다.

    • 이렇게 하면 부모 오브젝트가 회전해도, 자식 오브젝트들은 부모의 로컬 스페이스 기준으로 상대적인 위치와 방향을 유지합니다. 그러므로 Raycast를 쏠 때는 다음과 같이 할 수 있습니다.

      // 앞쪽 Raycast
      Vector3 frontPos = frontPoint.transform.position;
      Vector3 frontDir = frontPoint.transform.forward;
      Physics.Raycast(frontPos, frontDir, out hit, distance);
      
      // 뒤쪽 Raycast
      Vector3 backPos = backPoint.transform.position;
      Vector3 backDir = backPoint.transform.forward;
      Physics.Raycast(backPos, backDir, out hit, distance);
    • 부모 오브젝트가 90도, 180도 회전을 하더라도, frontPointbackPoint는 그에 따라 회전하므로 항상 "앞"과 "뒤"를 올바르게 지칭합니다.

  2. TransformPoint와 로컬 오프셋 사용하기:
    자식 오브젝트 없이 코드 상에서 처리하고 싶다면, 오브젝트의 로컬 좌표상에서 앞/뒤 위치를 오프셋으로 정의한 뒤 TransformPoint()를 통해 월드 좌표로 변환할 수 있습니다. 예를 들어, 오브젝트의 로컬 앞은 (0, 0, forwardOffset), 뒤는 (0, 0, -backwardOffset)로 설정한다고 하겠습니다.

    // 오브젝트 로컬 좌표 상의 앞/뒤 오프셋
    Vector3 localFrontOffset = new Vector3(0f, 0f, 1f); 
    Vector3 localBackOffset = new Vector3(0f, 0f, -1f);
    
    // 월드 공간으로 변환
    Vector3 frontPos = transform.TransformPoint(localFrontOffset);
    Vector3 backPos = transform.TransformPoint(localBackOffset);
    
    // 방향은 transform.forward와 transform.forward * -1 사용
    Vector3 frontDir = transform.forward;
    Vector3 backDir = -transform.forward;
    
    Physics.Raycast(frontPos, frontDir, out hit, distance);
    Physics.Raycast(backPos, backDir, out hit, distance);

위 두 방법 모두 오브젝트가 회전하면 transform.forward나 자식 오브젝트들의 transform 값이 자동으로 갱신되므로, 특별한 추가 계산 없이도 "앞"과 "뒤" 위치에서 Raycast를 정확히 쏠 수 있습니다.
즉, 핵심은 "회전 변화를 반영하는 transform의 forward, right, up 벡터 및 TransformPoint 등의 함수를 사용"하거나, "앞뒤에 배치된 자식 오브젝트를 이용"하는 것입니다.

    void Start()
    {
        float boundSize = transform.GetComponent<SpriteRenderer>().bounds.size.x;
        // 오브젝트 로컬 좌표 상의 앞/뒤 오프셋
        localFrontOffset = new Vector3(boundSize / 2, 0, 0);
        localBackOffset = new Vector3(-boundSize / 2, 0, 0);

        rayDirection = Vector3.back;
    }

    void Update()
    {
        Vector3 frontPos = transform.TransformPoint(localFrontOffset);
        Vector3 backPos = transform.TransformPoint(localBackOffset);
        Ray ray1 = new Ray(frontPos, rayDirection);
        Ray ray2 = new Ray(backPos, rayDirection);
        RaycastHit hit1, hit2;
        Physics.Raycast(ray1, out hit1, rayLength);
        Physics.Raycast(ray2, out hit2, rayLength);
        if (hit1.collider != null) Debug.Log("Hit1 : " + hit1.collider);
        if (hit2.collider != null) Debug.Log("Hit2 : " + hit2.collider);

    }

로컬 오프셋 썼다.

앞뒤 구분 잘 되는거 확인.
각도 바꿔도 앞과 뒤 구분 확실했다.

콜라이더 어디에 달려있는지 실수

int hit1Index = hit1.collider?.GetComponent<NoteBlock>()?.noteBlockIndex ?? -1;
int hit2Index = hit2.collider?.GetComponent<NoteBlock>()?.noteBlockIndex ?? -1;

무난하게 구현 중 raycast가 쏜 block의 index가 탐지 안되는 현상 발생.
noteblock 스크립트는 콜라이더가 아니라 부모 오브젝트에 달려있기 때문.

int hit1Index = hit1.collider?.GetComponentInParent<NoteBlock>()?.noteBlockIndex ?? -1;
int hit2Index = hit2.collider?.GetComponentInParent<NoteBlock>()?.noteBlockIndex ?? -1;

gpt한테 물어봤더니 유용한 메서드를 알려줬다.

오늘 결과물

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bar_Judge_Movement : MonoBehaviour
{
    Dictionary<string, int> noteDirections;
    bool isInBox = false;
    float rayLength = 1f;
    Vector3 localFrontOffset;
    Vector3 localBackOffset;
    Vector3 rayDirection;
    RaycastHit hit1, hit2;
    int missionBlockIndex;
    Collider missionBlockCollider;
    NoteBlock missionBlockScript;
    KeyType missionKeyType;
    KeyType keyInput;

    [SerializeField] float bpm;
    float sec_per_quarter;
    float speed;
    [SerializeField] Vector3 goingDirection = Vector3.right; // Initial direction

    void Start()
    {
        float boundSize = transform.GetComponent<SpriteRenderer>().bounds.size.x;
        localFrontOffset = new Vector3(boundSize / 2, 0, 0);
        localBackOffset = new Vector3(-boundSize / 2, 0, 0);
        rayDirection = Vector3.back;
        sec_per_quarter = 60f / bpm;
        speed = 1f / sec_per_quarter;
    }

    void Update()
    {

        RaycastFrontAndBack();
        CheckInbox();
        if (isInBox) Judge();
        // transform.Translate(goingDirection * speed * Time.deltaTime, Space.World);
        transform.Translate(goingDirection * speed * Time.deltaTime);
    }

    /// <summary>
    /// 오브젝트의 로컬 좌표 기준 앞뒤에서 raycast
    /// </summary>
    private void RaycastFrontAndBack()
    {
        Vector3 frontPos = transform.TransformPoint(localFrontOffset);
        Vector3 backPos = transform.TransformPoint(localBackOffset);
        Ray ray1 = new Ray(frontPos, rayDirection);
        Ray ray2 = new Ray(backPos, rayDirection);
        Physics.Raycast(ray1, out hit1, rayLength);
        Physics.Raycast(ray2, out hit2, rayLength);
        // if (hit1.collider != null) Debug.Log("Hit1 : " + hit1.collider);
        // if (hit2.collider != null) Debug.Log("Hit2 : " + hit2.collider);
    }

    /// <summary>
    /// 지금 쳐야할 노트에 들어왔는지, 나갔는지 판단
    /// </summary>
    private void CheckInbox()
    {
        int hit1Index = hit1.collider?.GetComponentInParent<NoteBlock>()?.noteBlockIndex ?? -1;
        int hit2Index = hit2.collider?.GetComponentInParent<NoteBlock>()?.noteBlockIndex ?? -1;
        // Debug.Log("Hit1 : " + hit1Index + ", Hit2 : " + hit2Index);

        // 이번에 쳐야할 블럭에 진입
        if (hit1Index == missionBlockIndex || hit2Index == missionBlockIndex)
        {
            if (!isInBox)
            {
                Debug.Log("InBox");
                if (hit1Index == missionBlockIndex) missionBlockCollider = hit1.collider;
                else missionBlockCollider = hit2.collider;
                missionBlockScript = missionBlockCollider.GetComponentInParent<NoteBlock>();
                missionKeyType = missionBlockScript.requiredKeys;
                isInBox = true;
                keyInput = 0;
            }
        }
        // hit 전에 블럭을 빠져나감
        else
        {
            if (isInBox)
            {
                Debug.Log("Game Over");
            }
        }
    }

    private void Judge()
    {
        if (Input.GetKeyDown(KeyCode.UpArrow)) keyInput |= KeyType.Up;
        if (Input.GetKeyDown(KeyCode.DownArrow)) keyInput |= KeyType.Down;
        if (Input.GetKeyDown(KeyCode.LeftArrow)) keyInput |= KeyType.Left;
        if (Input.GetKeyDown(KeyCode.RightArrow)) keyInput |= KeyType.Right;

        // 잘못된 키를 눌렀다면 게임 오버
        if ((keyInput | missionKeyType) != missionKeyType)
        {
            Debug.Log("Game Over");
        }
        // 필요한 키 다 눌렀으니 판정 성공
        else if (keyInput == missionKeyType)
        {
            Debug.Log("Success");
            missionBlockScript.RemoveBlock();
            missionBlockIndex++;
            TurnMovement();
            isInBox = false;
        }
    }

    private void TurnMovement()
    {
        Vector3 nextDirection = Vector3.zero;

        if ((missionKeyType & KeyType.Up) != 0) nextDirection += Vector3.up;
        if ((missionKeyType & KeyType.Down) != 0) nextDirection += Vector3.down;
        if ((missionKeyType & KeyType.Left) != 0) nextDirection += Vector3.left;
        if ((missionKeyType & KeyType.Right) != 0) nextDirection += Vector3.right;

        float angle = Mathf.Atan2(nextDirection.y, nextDirection.x) * Mathf.Rad2Deg;
        transform.rotation = Quaternion.Euler(0, 0, angle);
    }
}

이제 슬슬 게임다워지기 시작한다

profile
너 정말 **핵심**을 찔렀어

0개의 댓글