세피리아 참고 프로젝트 - 4. 기본 맵 구현

승민·2026년 9월 7일

세피리아 모작

목록 보기
4/7

세피리아(Sephiria) 게임을 참고하여 진행한 작업내역을 기록하기 위한 글 입니다.

세피리아 모작(github)

구현 맵

가장 기초적인 마을 맵과 던전 진입 직후 첫번째 방을 구현해보았다.


주요 기능

카메라 범위 제한

빈 오브젝트를 하나 만들고, 트리거를 활성화 한 채 콜라이더를 카메라 제한 범위에 맞춰서 설정해주었다.

카메라는 시네머신 카메라를 이용하여 플레이어를 추적하도록 구성되어 있는데, 그 시네머신 카메라의 컴포넌트로 Cinemachine Confiner 2D를 추가하고, 대상으로 설정해둔 콜라이더를 넣어줌으로써 카메라가 이동할 수 있는 범위를 제한한다.

이 설정으로 인해 플레이어는 설정해놓은 범위를 넘어서는 영역을 볼 수 없게된다.

특정 구역에서의 카메라 시점 변환


세피리아 마을 맵에서 좌측 상단으로 갔을 때 카메라가 이동되면서 배경이 넓게 보이는 부분을 구현해보았다.

플레이어를 추적하는 기본 시네머신 카메라 대신, 새로운 시네머신 카메라를 생성해서 새 시네머신 카메라의 위치를 시점 변환이 진행될 위치에 배치해준 다음 비활성화를 진행해두었다.

이때 가장 중요한건 우선순위(Priority) 옵션으로, 메인 카메라의 제어권을 가질 시네머신 카메라의 우선 순위를 의미한다.

 
다음으로 특정 영역에 트리거를 설치해두고, 플레이어가 그 영역 안으로 들어가게 되면 생성해둔 시네머신 카메라의 활성화를 진행하고, 플레이어가 트리거를 빠져나갈 때 다시 고정영역 시네머신 카메라의 비활성화를 진행하도록 구성해주었다.

//현재 영역에 무언가가 들어왔을 때
private void OnTriggerEnter2D(Collider2D other)
{
    //플레이어가 아니라면 패스
    if (!IsPlayer(other)) return;

    //플레이어 콜라이더가 목록에 새로 추가되고, 목록에 1개만 존재하는지 확인
    if (!playerColliders.Add(other) || playerColliders.Count != 1)
        return;

    //그렇다면 고정 카메라 활성화
    if (fixedCamera != null)
        fixedCamera.gameObject.SetActive(true);
}

//현재 영역에서 무언가가 빠져나갔을 때
private void OnTriggerExit2D(Collider2D other)
{
    //플레이어 목록에서 제거시켜주면서, 그로 인해 목록에 아무것도 없게되는지 확인
    if (!playerColliders.Remove(other) || playerColliders.Count > 0)
        return;

    //그렇다면 고정 카메라 비활성화
    if (fixedCamera != null)
        fixedCamera.gameObject.SetActive(false);
}

실제 코드 또한 단순하게 콜라이더에 들어오고 빠져나간 객체가 플레이어인지를 체크하며, 그에 맞춰서 고정영역용 시네머신 카메라 객체를 활성화 / 비활성화 해주도록 되어있다.

메인 카메라의 Cinemachine Brain 컴포넌트에서 블렌드 방식을 지정해주었기에 메인 카메라를 통제하는 시네머신 카메라가 변경될 때 마다, 그에 맞춰서 시점 변환이 자연스럽게 진행된다.

또한, 이미지를 보면 알 수 있듯이 배경은 parallax를 이용해 원근감을 부여해주어 이 카메라 전환 연출을 더욱 살려주었다.

오브젝트 뒤 플레이어 반투명 처리

플레이어 캐릭터가 오브젝트 뒤쪽으로 들어갔을 때, 단순하게 가려지기만 한다면 플레이어 입장에서는 현재 내 캐릭터가 어느 위치에 있는지 즉각적인 파악이 어려워진다.

만약 이 상황에서 오브젝트의 배치들로 인해 캐릭터가 어딘가에 걸려버린다면 플레이어는 캐릭터가 보이지 않기 때문에 게임을 플레이 하는 과정에서 불편함을 느낄 수 있다.

플레이어 영역

[Serializable]
private class RendererPair
{
    public SpriteRenderer sourceRenderer; //원본 스프라이트(플레이어 본체, 무기 방패)
    public SpriteRenderer occlusionRenderer; //반투명 출력용 스프라이트(플레이어 본체, 무기 방패)
}

//각각의 이미지를 인스펙터에서 연결
[SerializeField] private RendererPair[] rendererPairs;

현재 플레이어의 스프라이트 렌더러는 플레이어 캐릭터 본체, 무기, 방패 3개로 구성되어 있기 때문에 메인 스프라이트 렌더러와 반투명용 스프라이트 렌더러를 각각 연결해줄 수 있게 별도의 클래스로 구성해주었다.

이를 이용하여 인스펙터를 통해 플레이어 캐릭터 본체와 본체 반투명 처리용 별도 스프라이트 렌더러, 검과 반투명 처리용 별도 스프라이트 렌더러, 방패와 반투명 처리용 별도 스프라이트 렌더러를 각각 연결해주었다.

이때, 이 반투명 처리용 스프라이트 렌더러는 그려지는 우선 순위를 오브젝트보다 더 높게 설정해두어서 항상 오브젝트보다 더 앞쪽에 그려질 수 있게 해둔다.

//스프라이트 렌더러 동기화
private void SyncRenderers()
{
    foreach (RendererPair rendererPair in rendererPairs)
    {
        SpriteRenderer sourceRenderer = rendererPair.sourceRenderer; //원본 스프라이트 렌더러
        SpriteRenderer occlusionRenderer = rendererPair.occlusionRenderer; //반투명용 스프라이트 렌더러

        if (sourceRenderer == null || occlusionRenderer == null) continue;

        Transform sourceTransform = sourceRenderer.transform; //원본 스프라이트 렌더러 트랜스폼
        Transform occlusionTransform = occlusionRenderer.transform; //반투명용 스프라이트 렌더러 트랜스폼

        //원본 렌더러의 위치, 방향, 스케일 그대로 적용
        occlusionTransform.SetPositionAndRotation(sourceTransform.position, sourceTransform.rotation);
        occlusionTransform.localScale = sourceTransform.localScale;

        //원본 이미지의 이미지, 플립 상태 또한 그대로 적용
        occlusionRenderer.sprite = sourceRenderer.sprite;
        occlusionRenderer.flipX = sourceRenderer.flipX;
        occlusionRenderer.flipY = sourceRenderer.flipY;

        //원본 색상에서 알파값만 설정값에 따라 감소
        Color sourceColor = sourceRenderer.color;
        sourceColor.a *= occlusionAlpha;
        occlusionRenderer.color = sourceColor;
        
        //반투명 이미지 활성화
        occlusionRenderer.enabled = sourceRenderer.enabled;
    }
}

매 프레임 동작하며 반투명 처리를 진행하는 메인 코드인데, 내용은 간단하다.
두 스프라이트 렌더러를 가져와서 회전이나 플립, 이미지 등 원본 렌더러의 정보를 반투명용 렌더러에도 그대로 적용해준다.

이후 원본 색상을 그대로 가져와서 별도로 설정해놓은 알파값 배율(0~1)을 적용한 이후 반투명용 렌더러에 적용하고 활성화 해주도록 되어있다.

즉, 원본 이미지를 단순히 반투명하게 출력해주도록 되어있는데, 이 반투명 이미지가 그려지는 우선 순위가 오브젝트보다 높기 때문에 항상 오브젝트 앞쪽에서 출력되면서 플레이어를 반투명하게 표현한다.

다만 이 상태만으로는 이미지 자체에 반투명한 부분이 원래 존재하는 경우 그 반투명한 부분이 의도보다 더 진하게 표현될 수 있으며, 반투명 처리를 굳이 하고싶지 않은 오브젝트에도 플레이어 반투명 처리가 진행되게 된다.

따라서 이 반투명 이미지가 출력되는 범위를 제한해둘 필요가 있는데, 이 스크립트의 Awake에는 사실 다음과 같은 내용이 포함되어 있다.

private void Awake()
{
    //본체, 무기, 방패 스프라이트들을 돌면서 
    foreach (RendererPair rendererPair in rendererPairs)
    {
        if (rendererPair.occlusionRenderer == null) continue;

        //반투명 이미지를 마스크 영역 안쪽에서만 표시 (VisibleInsideMask)
        rendererPair.occlusionRenderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
    }
}

위 코드에 의해 반투명용 스프라이트 렌더러를 통한 이미지는 마스크 영역 안쪽에서만 출력되게 되는데, 이 마스크는 유니티의 SpriteMask 기능을 이용하여 오브젝트 관련 코드에서 생성된다.

 
참고로 이 SpriteMask 기능은 내부적으로 스텐실(Stencil)을 이용한 마스킹 방식을 기반으로 하고있다.
스텐실 마스크란, 스텐실 버퍼를 이용해서 화면의 픽셀마다 별도의 값(0,1 등)을 기록해두고, 렌더링 과정에서 이 값을 이용해 픽셀들의 렌더링 여부를 결정할 수 있게하는 기능을 의미한다.

위의 코드에서, 스프라이트 마스크에 대한 설정을 VisibleInsideMask으로 지정해줬기 때문에, 오브젝트 스프라이트에 맞춰서 스텐실 버퍼가 채워지고, 플레이어의 이미지들은 이 스텐실 버퍼 안쪽에 들어가면 그려지게 되는 구조가 된다.

오브젝트 영역

오브젝트 영역의 동작은 크게 플레이어에게 마스크를 전달하고, 플레이어가 영역에 존재하는 한 마스크를 계속해서 업데이트 해주며, 플레이어가 영역에서 벗어나면 마스크를 다시 원래대로 되돌려 받도록 구성되어있다.

//플레이어에게 마스크 전달
private void AttachMasks(Transform maskRoot)
{
    foreach (MaskData data in maskData)
    {
        if (data.spriteMask == null) continue;

        //오브젝트의 마스크를 플레이어에게 연결
        Transform maskTransform = data.spriteMask.transform;
        maskTransform.SetParent(maskRoot, true);
    }
}

인스펙터를 통해 연결해둔 maskData의 SpriteMask 컴포넌트를 플레이어의 반투명 스프라이트 렌더러들을 보유하고 있는 내부 오브젝트에 연결해준다.

플레이어 영역에서 본 VisibleInsideMask는 기본적으로 마스크 안쪽이라면 문제없이 동작하지만, 문제는 플레이어가 SortingGroup 컴포넌트를 사용하고 있다는 점에 있다.

SortingGroup은 내부적으로 여러 스프라이트 렌더러가 있을 때, 가장 대표가 되는 렌더링 우선 순위를 정하게 해주는 컴포넌트로, 현재 플레이어는 본체, 검, 방패로 이루어져 있고 각각 렌더링 순서가 다르기 때문에 SortingGroup을 통해 렌더링 순서를 맞춰주고 있다.

만약 SortingGroup을 아예 사용하지 않았다면 오브젝트 뒤로 들어갔을 때, 플레이어와 검은 보이지 않지만 방패가 있는 그대로 오브젝트를 뚫고 나오는 문제가 있기 때문에 플레이어에 대한 SortingGroup은 필수라고 할 수 있다.

문제는 이 SpriteMask가 VisibleInsideMask에 해당하는 마스크가 되기 위해서는 유효한 SortingGroup 내부에 존재해야 한다는 점에 있다.

플레이어에게 SortingGroup이 존재하기 때문에 마스크가 그대로 오브젝트에 붙어있다면 같은 SortingGroup에 있다고 할 수 없다.

애초에 플레이어에게 SortingGroup이 없다 하더라도, 오브젝트에 SortingGroup이 존재한다면 그 또한 SortingGroup 내부 전용 마스크가 되기 때문에 동작할 수 없다.

만약 플레이어와 오브젝트에 둘 다 SortingGroup이 존재하지 않는다면 플레이어쪽으로 마스크를 전달하지 않아도 반투명 이미지들이 마스크의 영향을 받을 수 있지만, 현재 구조로써는 SortingGroup이 필수이기 때문에 결과적으로 플레이어의 반투명 이미지쪽으로 마스크를 넘겨주어야만 한다.

//마스크 동기화
private void SyncMasks()
{
    foreach (MaskData data in maskData)
    {
        if (data.sourceRenderer == null || data.spriteMask == null) continue;

        SpriteRenderer sourceRenderer = data.sourceRenderer; //오브젝트 메인 스프라이트 렌더러
        Transform sourceTransform = sourceRenderer.transform; //오브젝트 스프라이트 렌더러 트랜스폼
        Transform maskTransform = data.spriteMask.transform; //오브젝트의 스프라이트 마스크 트랜스폼

        data.spriteMask.sprite = sourceRenderer.sprite; //오브젝트 메인 이미지를 마스크에도 그대로 적용

        maskTransform.SetPositionAndRotation(sourceTransform.position, sourceTransform.rotation); //마스크의 위치 및 회전을 메인 이미지랑 맞춰줌

        Vector3 sourceScale = sourceTransform.lossyScale; //오브젝트 최종 스케일
        Vector3 parentScale = maskTransform.parent != null ? maskTransform.parent.lossyScale : Vector3.one; //부모(플레이어)의 최종 스케일

        //원본 스프라이트가 플립되어 있을 가능성도 고려
        float flipX = sourceRenderer.flipX ? -1f : 1f;
        float flipY = sourceRenderer.flipY ? -1f : 1f;

        //마스크의 스케일 조정, 플레이어의 스케일이 다른 경우에도 오브젝트의 마스크는 항상 오브젝트와 동일한 스케일을 갖게 함
        maskTransform.localScale = new Vector3(
            DivideScale(sourceScale.x, parentScale.x) * flipX,
            DivideScale(sourceScale.y, parentScale.y) * flipY,
            DivideScale(sourceScale.z, parentScale.z)
        );
    }
}

매 프레임 동작하며 마스크를 관리해주는 코드로, 오브젝트 자체에 애니메이션 등으로 인해 크기나 회전 등이 변경되거나, 이미지가 변경되는 경우를 대비하며 마스크를 항상 오브젝트와 동일하게 관리해주는 코드이다.

단순하게 메인 스프라이트 렌더러의 이미지를 가져와서 그대로 마스크의 이미지로도 등록해주고, 메인 이미지의 위치와 회전을 있는 그대로 적용해주며, 스케일이나 플립 또한 있는 그대로 적용해주도록 되어있다.

스케일의 경우 조금 특이한데, 마스크는 이미 플레이어쪽에 전달되어 있는 상태인데, 오브젝트와 플레이어의 스케일 값이 서로 다를 수 있기 때문에 그에 맞춰 마스크의 크기가 변동되어 오브젝트의 실제 이미지와 마스크의 크기가 달라질 가능성이 존재한다.

따라서 오브젝트 이미지의 최종 스케일과, 플레이어 쪽으로 전달된 마스크 부모의 스케일을 각각 가져와서 오브젝트 스케일 / 플레이어 스케일 처리를 진행하고, 이를 최종 스케일로 마스크에 적용해준다.

이런 과정들을 통해 마스크는 항상 오브젝트의 이미지와 완전히 동일한 크기와 형태를 갖게 되며, 플레이어 반투명 이미지는 항상 VisibleInsideMask, 즉 마스크 안쪽에서만 동작하도록 되어있기 때문에, 대상 오브젝트 뒤로 들어가게 되면 정확하게 그 오브젝트의 이미지 영역 안에서만 플레이어의 반투명 이미지가 출력되게 된다.

오브젝트 파괴

오브젝트 파괴의 경우 기존의 전투 로직(세피리아 공격 파이프라인)을 이용해서 비교적 간단하게 구현할 수 있었다.

 

public class BreakableObject : MonoBehaviour, IDamageable

가장 먼저 공격을 받을 수 있는 객체를 뜻하는 IDamageable 인터페이스를 이용해서 파괴 가능 오브젝트 로직을 구현해주었다.

public enum HitResultType
{
    Ignored, //사망 혹은 파괴상태 등 판정 무시에 사용
    Hit,
    Evade,
    Guard,
    PerfectGuard,
    GuardBreak,
    ObjectHit
}

이후, 공격 처리 결과인 DamageResult 구조체에서 사용되는 HitResultType에 ObjectHit를 추가해주었다.

마지막으로, 플레이어의 공격 히트박스 대상 레이어에 파괴 가능 오브젝트 전용 레이어를 등록해주었다.

이 세가지 간단한 처리만으로 기존의 전투 로직 흐름에 올라타게 되었다.

즉, 기존의 전투 로직과 동일하게 플레이어가 공격을 진행할 때, 공격 판정 범위에 파괴 가능 오브젝트가 존재하는지 탐색을 진행하고, 파괴가능 오브젝트가 있다면 BreakableObject의 TakeDamage 로직이 실행된다.

따라서 TakeDamage 메소드에 오브젝트 파괴 기능을 넣어주기만 하면 오브젝트 파괴 기능을 구현할 수 있게된다.

//피격(파괴) 처리
public DamageResult TakeDamage(AttackData attackData)
{
    //이미 파괴되었으면 패스
    if (isBroken)
    {
        return new DamageResult(
            0f,
            false,
            DamageResult.HitResultType.Ignored,
            attackData.element,
            attackData.source
        );
    }

    //파괴되지 않았다면 파괴처리 진행
    Break();

    //이벤트를 위한 ObjectHit 판정 반환
    return new DamageResult(
        0f,
        false,
        DamageResult.HitResultType.ObjectHit,
        attackData.element,
        attackData.source
    );
}

오브젝트의 TakeDamage 메소드는 오브젝트가 이미 파괴되었는지 체크하고 파괴되었다면 데미지 타입을 Ignored로 전달하고, 그렇지 않다면 파괴처리 진행 후 데미지 타입을 ObjectHit로 전달한다.

데미지 폰트 및 피격판정 단계에서 ObjectHit 타입은 그대로 return을 해주도록 되어있기 때문에 오브젝트가 파괴되는 경우엔 몬스터를 공격할 때와 같은 데미지 폰트가 발생하지 않는다.

실질적 파괴처리인 Break 메소드는 오브젝트를 파괴(제거)하면서 등록해놓은 파편 프리팹들이 흩날리게 된다.
파괴의 경우 기존 오브젝트를 아예 삭제할 수도 있으며, 오브젝트에 따라서 파괴 후 오브젝트가 남아있을 수도 있게 구현해주었다.

위 이미지의 횃대를 통해 파괴 이후 파괴된 횃대 이미지가 계속해서 남아있는 경우를 확인할 수 있으며, 박스의 경우엔 남아있는 오브젝트 없이 파편들만 흩날리고 전부 사라지는 것을 확인할 수 있다.

0개의 댓글