[전통 한식 요리 RPG 게임 Michelin De Hanyang] 2. 개발

백지윤·2024년 5월 20일

프로젝트

목록 보기
4/8

프로젝트 기간

플레이어 스프라이트

2024-02-19 ~ 2024-04-04

최종 GITHUB

GITHUB 프로젝트

주요 기능

1.사냥터
	- 조작
    - 몬스터
    - 채집
    - 낚시
    - 보물상자
2.주막
	- 영업 시작 전
    	- 오늘의 메뉴
        - 레시피 리스트 확인
    - 영업 시작 후
    	- NPC 이동 로직
        - 플레이어 서빙 로직
	- 영업 종료
    	- 판매 결과 창 화면
3.상점
	- 대장간
    - 마굿간
    - 상점
4.기타
	- BGM
    - 퀘스트
    - 인증/인가, 게임 저장, 게임 종료
5.웹 배포 사이트
	- 게임 소개
    - 게임 가이드
    - 커뮤니티
    - 랭킹
    - 고객 지원
  • 해당 게임의 기능은 이정도로 크게 나눌 수 있는데 여기서
    모든 게임의 UI/UX 부분에 참여해서 디자인에 관여하였고,
    2. 주막 기능의 영업시작 전, 영업 시작 후의 기능을 통해 게임 구현을 하였다.
    또,
    5.웹 배포 사이트를 React Typescript를 이용하여 모두 개발하였다.

  • 해당해서 자세히 개발한 과정을 보자면...


주막 기능 개발 (팝업창 구현)

- 영업 시작 전
    - 오늘의 메뉴를 통해 판매할 메뉴 개수를 설정할 수 있음.
    - 인벤토리에 가지고 있는 재료에 따라서 최대 요리 개수를 제한함.
    - 밥, 김치, 도토리묵은 기본 요리로, 재료에 따라 제한 없이 최대 3개까지 요리가 가능하도록 제한
    - 요리 배우기를 통해 사냥터에서 획득한 레시피를 이용하여 요리를 배울 수 있음.

  • 일단 영업시작 전 보여주어야할 창들은 총 2개였는데,
  1. 오늘의 메뉴(팝업)
  2. 요리 배우기(팝업)
    이었다.

해당 판넬(유니티 판넬 2D object 생성)을 클릭했을때, 모달 창이 띄우는것 처럼 표현하는 것은 웹에서의 구현방식과는 많이 달랐는다. 요약하자면,


  1. UI 캔버스 설정

  2. 팝업 패널 생성

  3. 팝업 내용 추가

  4. 스크립트로 팝업 제어

  • 팝업을 제어하는 스크립트를 작성해야하는데, 아래의 예시의 스크립트를 작성하면 된다.
using UnityEngine;
using UnityEngine.UI;

public class PopupManager : MonoBehaviour
{
    public GameObject popupPanel; // 팝업 패널
    public Text popupText; // 팝업 텍스트

    // 팝업을 표시하는 함수
    public void ShowPopup(string message)
    {
        popupText.text = message;
        popupPanel.SetActive(true);
    }

    // 팝업을 숨기는 함수
    public void HidePopup()
    {
        popupPanel.SetActive(false);
    }
}
  1. 팝업 시스템 연결
  • Canvas에 PopupManager 스크립트를 추가해서 연결

  • 버튼에 함수 연결:
    팝업을 닫는 버튼의 Button 컴포넌트에서 On Click() 이벤트를 추가
    PopupManager를 드래그하여 추가한 후, HidePopup 함수를 선택

  1. 팝업 호출
  • 게임 로직에서 팝업을 호출

이런식으로, 해당 팝업에 들어갈 버튼 요소 (o,x), 내용들을 TextMeshPro와 같은 텍스트를 이용해서 작성한 뒤 해당 판넬 오브젝트를 어떤 버튼을 클릭시에 띄워줄지(PopUp) 스크립트를 통해 연결시키면 된다.

  • 우리는 이런식으로 미리 판넬이나 버튼 에셋을 게임 분위기에 맞게 생성하였기 때문에 해당 팝업창 시스템의 통일성을 유지할 수 있었다.

주막 기능 개발 (손님 및 플레이어 로직)

- 영업 시작 후
    - 손님이동 로직
        - 특정 SPOT에서 NPC를 적절한 시간의 간격을 두고 생성 후, 테이블의 타겟 위치를 설정해서 AI 2D NAVMESH를 통해 최적의 루트를 찾아 움직이게 하도록 구현
    - 플레이어 서빙 로직
        - 빛이 나는 테이블에서 z키를 눌러서 랜덤하게 서빙할 요리를 선택하도록 구현
        - 손님이 요구하는 요리의 Sprite와 플레이어가 들고 있는 요리의 Spite를 비교해서 일치하면 서빙하도록 기능 구현.

  • 위 로직을 구성할 때, 제일 시간 투자를 많이 했었는데 이게 주막씬의 핵심 기능이었기 때문이다.

위 기능을 완료하기 위해 거친 스텝들을 정리하자면

1. 주막 타일 맵 제작 
2. 플레이어 및 손님 SPRITE ANIMATION 만들기 
(WALK, STAND 두개로 나누어서 애니메이션 연결)
3. 손님을 생성할 장소를 COLLIDER로 지정
4. 손님이 지정해서 갈 장소를 리스트로 저장
5. 리스트의 TARGET 값들의 BOOLEAN값이 FALSE(자리가 비어있음)면 해당 TARGET을 잡도록 랜덤하게 지정
6. 해당 타겟 지정후, 손님이 2D PATHFINDING (NAVMESH 이용)을 이용하여 경로 설정 후 이동
7. 이동 후, 오늘의 메뉴 중 등록된 메뉴를 머리 위의 스프라이트로 표시 (오늘의 메뉴 참조) 
8. 플레이어는 식탁에 가서 메뉴 픽업 후 (오늘의 메뉴 참조) 해당 손님에게 음식 전달
9. 플레이어가 들고 있는 음식과 손님이 원하는 음식 대조 후, 맞으면 3초 정도 후에 움직이며 다시 스폰 장소로 이동하며 삭제 
  • 이정도이다.. 이 기능 구현이 이 프로젝트에서 내게 굉장한 과제이기도 하였고, 제일 재밌었던 기능 구현이었다.
  • 원래 2D에서는 A 로직을 써서 경로 찾기를 제일 많이 하는데, 사실 이 기능 구현은 내가 아니라 다른 팀원이 하다가 중도 포기하고 내가 맡게 되어 처음부터 다시 짠 로직인데 그 팀원이 A 를 사용했는데, 구현이 잘 안된것 같아 2D NAVMESH를 이용하여 구현하였다.

단계별로 간략하게 소개를 하자면,

1. 주막 타일 맵 제작

  • 일단 위의 2D 맵은 원래는 그냥 스프라이트 형식의 하나의 이미지였다.. 근데 해당해서 pathfinding을 하려면 타일형식의 맵이 필요할것 같아서 타일들을 직접 만들어서 하나하나 찍어서 맵 구성을 완료했다.
  • 타일은 게임분위기에 알맞게 DALLE를 통해서 하나의 타일과 이미지들을 조합해서 SUPERTILED라는 유니티 에셋을 통해 구현하였다.

  • 이 두개 이미지로 조각조각 내어서 완성한 맵을 바탕으로 TMX 파일로 export 해서 유니티 에셋 내에 비치 후 바로 드래그앤 드롭으로 해당 맵을 비치하면 된다.
    • 이 과정에서 에러가 어마무시하게 나서 여러가지 구글링을 통해서 디버깅을 하고 무사히 맵을 비치할 수 있었다. 났었던 에러는 상대 경로와 관한 에러들이었다. 이 TMX 파일을 EXPORT 할때 안에서 사용된 PNG 파일들의 위치가 정확히 지정이 안되어있을수도 있어서 직접 스크립트 내에서 바꿔줘야할 수 있다. 어쨌든 구글링을 통해 무사히 구현..

2. 플레이어 및 손님 SPRITE ANIMATION 만들기 (WALK, STAND 두개로 나누어서 애니메이션 연결)

  • 이건 관련해서 유튜브에 애니메이션 만드는 영상이 너무 많아서 참조해서 무사히 할 수 있었다.
  • 손님과 플레이어 모두 상하좌우 스프라이트를 연결해
    • WALK
    • STAND-UP
  • 이 두가지 자세의 애니메이션을 만들었고 두개는 KEY 버튼이 눌릴때, STAND-UP -> WALK로 바뀌는 방식으로 만들어서 자연스러운 움직임을 보여줄 수 있었다.

3. 손님을 생성할 장소를 COLLIDER로 지정

  • SUPERTILED 자체에서 COLLIDER로 지정할 수 있을 수 있어 미리 지정을 해서 해당 영역을 OBJECT로 만들어놓고 스크립트에서 그 OBJECT를 지정해서 생성할 수 있게 작성했다.
void SpawnCustomerAtSpawnPoint() {
    SuperObject[] superObjects = FindObjectsOfType<SuperObject>();

    foreach (var obj in superObjects) {
        if (obj.name == "Object_44") {
            var customProperties = obj.GetComponent<SuperCustomProperties>();

            if (customProperties != null ) {
                GameObject npc = Instantiate(npcPrefab, obj.transform.position, Quaternion.identity);
                var customerScript = npc.GetComponent<CustomerManager>();
                if (customerScript != null) {
                    customerScript.InitializeAgent();  // NavMeshAgent 초기화
                }
                break;
            }
        }
    }
}
  • SuperObject는 SuperTiled object 를 지정해주는 방법
  • 스폰 obj이름을 'Object_44'로 지정해두었다.

4. 손님이 지정해서 갈 장소를 리스트로 저장

리스트로 저장된 targets는 손님이 이동할 수 있는 장소를 나타낸다.

[SerializeField]
private List<Transform> targets; // 모든 타겟들을 저장하는 리스트
private List<bool> targetsActive; // 각 타겟의 활성 상태를 저장

void InitializeTargetsActive() {
    targetsActive = new List<bool>();
    foreach (var target in targets) {
        targetsActive.Add(target.gameObject.activeSelf);
    }
}
  • 위 타겟 부분은 함수를 짜서 유니티 내의 property 창에서 직접 위치들을 함수 리스트 안에 드래그앤 드롭으로 넣어주었다.

5. 리스트의 TARGET 값들의 BOOLEAN값이 FALSE(자리가 비어있음)면 해당 TARGET을 잡도록 랜덤하게 지정

  • 손님이 이동할 타겟을 랜덤하게 선택하는 로직을 스크립트로 작성하였다.
void MoveToSitArea() {
    targetsActive = new List<bool>();
    foreach (var target in targets) {
        targetsActive.Add(target.gameObject.activeSelf);
    }

    if (agent == null || targets == null || targetsActive == null) {
        return;
    }

    List<int> activeTargets = new List<int>();
    for (int i = 0; i < targetsActive.Count; i++) {
        if (targets[i] != null && targetsActive[i]) {
            activeTargets.Add(i);
        }
    }

    if (activeTargets.Count > 0) {
        int randomIndex = Random.Range(0, activeTargets.Count);
        int targetIndex = activeTargets[randomIndex];
        agent.SetDestination(targets[targetIndex].position);
        StartCoroutine(CheckIfArrived(targetIndex, targets[targetIndex].position));
    }
}
  • 이게 참조하는 값이 너무 많으니깐 if문을 통해 null 값을 처리해주는 로직을 짰다
  • 이렇게 해야하는 이유는 크게
1. 안정성 확보

- null 검사는 프로그램이 예상치 못한 상황에서 비정상적으로 종료되지 않도록 해야한다.
예를 들어, targets 리스트나 agent 객체가 null인 경우에 해당 리스트나 객체에 접근하려고 시도하면 
NullReferenceException이 발생할 수 있다.

2. 예외 처리 및 디버깅 용이

이 정도의 이유 때문이다.


6. 해당 타겟 지정후, 손님이 2D PATHFINDING (NAVMESH 이용)을 이용하여 경로 설정 후 이동

  • 해당 타겟 지정 후, 손님이 2D Pathfinding (NavMesh 이용)을 이용하여 경로 설정 후 이동
    손님이 이동할 때 NavMeshAgent를 사용하여 경로를 설정
public void InitializeAgent() {
    agent = GetComponent<NavMeshAgent>();
    if (agent != null) {
        agent.updateRotation = false;
        agent.updateUpAxis = false;
        MoveToSitArea();
    }
}

7. 이동 후, 오늘의 메뉴 중 등록된 메뉴를 머리 위의 스프라이트로 표시 (오늘의 메뉴 참조)

  • 이동 후, 오늘의 메뉴 중 등록된 메뉴를 머리 위의 스프라이트로 표시
    손님이 이동 후 도착했을 때, 머리 위에 메뉴를 표시
void OnTargetReached(int targetIndex) {
    targetsActive[targetIndex] = false;
    targets[targetIndex].gameObject.SetActive(false);
    ShowFoodImage(targetIndex);
    StartCoroutine(WaitAndChangeImageOrLeave(targetIndex, this.gameObject));
}

void ShowFoodImage(int targetIndex) {
    MenuManager menuManager = FindObjectOfType<MenuManager>();
    if (menuManager != null) {
        MenuItem randomFood = menuManager.GetRandomFoodItem();
        if (randomFood != null && randomFood.image != null && foodImageUI != null) {
            SpriteRenderer spriteRenderer = foodImageUI.GetComponent<SpriteRenderer>();
            if (spriteRenderer != null) {
                spriteRenderer.sprite = randomFood.image;
            }
        }
    }
}
  • 오늘의 메뉴를 담고 있는 스크립트인 MenuManager를 참조해서 GetRandomeFoodItem의 함수를 써서, 구현하였다.

8. 플레이어는 식탁에 가서 메뉴 픽업 후 (오늘의 메뉴 참조) 해당 손님에게 음식 전달

  • 해당해서 FoodEvent.cs, FoodArriveEvent.cs로 구현

FoodEvent.cs

  1. 플레이어와 특정 트리거의 상호작용 감지
private void OnTriggerStay2D(Collider2D collision) {
    if (!flag && Input.GetKey(KeyCode.Z) && thePlayer.GetAnimatorFloat("DirX") == -1f) {
        flag = true;
        StartCoroutine(EventCoroutine());
    }
}
  • OnTriggerStay2D 메서드에서 플레이어가 Z 키를 누르고 있는지 확인
  • 조건이 만족되면 flag를 true로 설정하고 EventCoroutine() 코루틴을 시작한다.
  1. 음식 아이템 선택 및 설정
IEnumerator EventCoroutine() {
    if (menuManager.todaysMenu.Count > 0) {
        currentFoodIndex = Random.Range(0, menuManager.todaysMenu.Count);
        var currentItem = menuManager.todaysMenu[currentFoodIndex];
        
        currentHeldFood = menuManager.todaysMenu[currentFoodIndex];
        var spriteRenderer = foodHolder.GetComponent<SpriteRenderer>();
        spriteRenderer.sprite = currentHeldFood.image;
        foodHolder.SetActive(true);
        Debug.Log("선택된 음식: " + currentHeldFood.name + ", 홀더 스프라이트: " + foodHolder.GetComponent<SpriteRenderer>().sprite.name);
    } else {
        Debug.Log("오늘의 메뉴가 비어있습니다.");
    }

    yield return new WaitForSeconds(0.5f);
    flag = false;
}
  • EventCoroutine() 메서드는 menuManager.todaysMenu가 비어 있지 않은지 일단 확인한다.
  • 비어 있지 않으면 Random.Range를 사용하여 메뉴 리스트에서 임의의 음식 아이템을 랜덤으로 선택.
  • 선택된 음식 아이템을 currentHeldFood로 설정하고, foodHolder의 스프라이트를 업데이트한다.
  • foodHolder 오브젝트를 활성화하여 플레이어가 들고 있는 음식이 보이도록 함.
  • 마지막으로, 0.5초 대기 후 flag를 false로 설정하여 다시 입력을 받을 수 있도록 한다.

FoodArriveEvent.cs

  1. 플레이어와 손님의 상호작용 감지
private void OnTriggerStay2D(Collider2D collision) {
    if (Input.GetKey(KeyCode.X) && collision.gameObject == thePlayer.gameObject) {
        float distance = Vector2.Distance(thePlayer.transform.position, transform.position);
        if (distance <= 5f) {
            CheckFoodDelivery();
            foodEvent.currentHeldFood = null;
            foodEvent.foodHolder.GetComponent<SpriteRenderer>().sprite = null;
        }
    }
}
  • OnTriggerStay2D 메서드에서 플레이어가 손님과 상호작용 가능한 범위 내에 있을 때 'X' 키를 눌렀는지 확인
  • 플레이어와 손님 사이의 거리가 특정 값( 여기서 5 유닛이 적당하다고 판정해서 5f로 설정) 이하일 때 CheckFoodDelivery() 메서드를 호출하여 음식을 전달
  1. 음식 배달 검사
private void CheckFoodDelivery() {
    if (foodEvent.currentHeldFood == null || customerManager.foodImageUI == null) {
        Debug.Log("No food to compare.");
        return;
    }

    Sprite playerFoodSprite = foodEvent.currentHeldFood.image;
    Sprite customerFoodSprite = customerManager.foodImageUI.GetComponent<SpriteRenderer>().sprite;

    if (playerFoodSprite == customerFoodSprite) {
        menuManager.ItemSold(customerFoodSprite);
        OnFoodDelivered?.Invoke(true, this.gameObject);
    } else {
        OnFoodDelivered?.Invoke(false, this.gameObject);
    }
}
  • CheckFoodDelivery() 메서드에서 플레이어가 들고 있는 음식(foodEvent.currentHeldFood)과 손님이 원하는 음식(customerManager.foodImageUI)을 비교

  • 두 음식의 이미지가 일치하면 menuManager.ItemSold(customerFoodSprite)를 호출하여 메뉴 매니저에 판매를 알리고, OnFoodDelivered 이벤트를 발생시킨다.

  • 일치하지 않으면 OnFoodDelivered 이벤트를 false 값과 함께 발생시킨다.


9. 플레이어가 들고 있는 음식과 손님이 원하는 음식 대조 후, 맞으면 3초 정도 후에 움직이며 다시 스폰 장소로 이동하며 삭제

private void HandleFoodDelivery(bool isCorrect, GameObject customer) {
    if (isCorrect && customer == this.gameObject) {
        SpriteRenderer customerSpriteRenderer = customer.transform.Find("Square/status").GetComponent<SpriteRenderer>();
        TextMesh customerTextMesh = customer.transform.Find("textstatus")?.GetComponent<TextMesh>();

        string[] satisfactionTexts = new string[] {
            "맛있네! 또 와야겠어!",
            "이렇게 맛있는 집이 있었다고?",
            "간만에 맛있게 먹었다!",
            "다음에 또 와야지..",
            "거 음식 잘하는 양반이네"
        };

        if (customerSpriteRenderer != null) {
            customerSpriteRenderer.enabled = false;
            if (customerTextMesh != null) {
                int randomIndex = Random.Range(0, satisfactionTexts.Length);
                customerTextMesh.text = satisfactionTexts[randomIndex];
                customerTextMesh.gameObject.SetActive(true);
                StartCoroutine(WaitAndMove(2));
            }
        }
    }
}

private IEnumerator WaitAndMove(float waitTime) {
    yield return new WaitForSeconds(waitTime);
    MoveToSpawnPoint();
}

public void MoveToSpawnPoint() {
    StartCoroutine(MoveToSpawnAndDestroy());
}

private IEnumerator MoveToSpawnAndDestroy() {
    if (agent != null) {
        agent.SetDestination(spawnPoint);
        while (!agent.pathPending && agent.remainingDistance > 0.1f) {
            yield return null;
        }
        yield return new WaitForSeconds(2);
        Destroy(gameObject);
    }
}
  • HandelFoodDelivery 함수에서 isCorrect 그리고, customer를 넘겨받아서 맞는 음식임을 확인했을때, satisfactionTexts에서 만족하며 text를 출력할 수 있도록 리스트 안에 5가지의 경우를 넣고 랜덤으로 출력하고, 2초 정도 기다렸다가 MoveToSpawnPoint() 함수를 출력해서 코루틴 시작후, Destroy 메서드를 써서 NPC를 삭제시킨다.

  • 이정도로 주막 기능 구현을 하였다.

조금 후회되는 부분은 CustomerLogic을 한 스크립트 안에 좀 때려박아서 디버깅 하는데 굉장히 힘들었다는 점......

  • 다음에는 조금... 리팩토링을 고려하면서 기능별로 스크립트를 나눠서 작성해야겠다..
  • 다음 2D 게임을 진행할 때는 A* 를 이용해서 로직을 구현해 보고 싶기도 하다.

웹 배포 사이트 개발 (리액트 타입스크립트 이용)

  • 일단,, 게임 프로젝트였어서 나머지 클라이언트가 조금 스케일이 더 컸던 사냥터나 상점 외의 개발에 집중하는 동안 혼자서 웹개발 사이트를 React Typescript를 이용해서 개발하게 되었다...
  • 혼자해서 편하기도 했지만, 피드백 줄 사람이 즉각 즉각 없어서 조금 불편했다.

구현 기능은 크게

- 게임 소개
- 게임 가이드
- 커뮤니티
- 랭킹
- 고객 지원

1. 게임 소개

  • 게임 소개를 어떤식으로 진행할까 고민하다가
    데이브 더 다이버 공식 홈
  • 위의 게임 소개에서 스크롤 형식으로 게임 소개 해주는 부분이 좋았어서 이런식으로 진행하려다가 소개할 내용들이 워낙 많아서 state로 해당해서 크게 대주제 5개 title 배열을 지정했고, 각각 title마다 배경 이미지를 지정해주었다. 그리고 그 안의 주제에서 소개할 내용들을 배열 안에 담아서 textIndex 함수를 통해서 현재 인덱스와 남은 인덱스 값을 통해 해당 부분을 클릭할 때마다 인덱스를 늘려서 보여주었다.
  • handleNext()

사용자가 페이지를 클릭할 때마다 호출된다.
현재 텍스트의 인덱스를 증가시켜 다음 텍스트를 표시함
현재 섹션의 모든 텍스트를 표시한 경우, 다음 섹션으로 넘어간다.

const GameIntro = () => {
  const [textIndex, setTextIndex] = useState(0);
  const [section, setSection] = useState(0); 
  const [accumulatedTexts, setAccumulatedTexts] = useState<string[]>([]);

  const textContent = [
  [
    "당신은 현대에서 유명한 미슐랭 스타 요리사였습니다.",
    "사업 확장과 다양한 프로젝트에 손을 뻗으며 성공의 정점에 있었으나, 운명은 잔혹했습니다.",
    ...
  [
    "이 게임은 단지 역사적 배경 속에서 요리를 하는 것이 아니라, 한국의 전통 문화와 한식의 아름다움을 세계에 전파하는 목적을 갖고 있습니다.",
    "플레이어는 메타버스와 게임의 결합을 통해, 조선 시대의 요리사로서의 삶을 체험하며, 한국의 맛과 문화를 깊이 있게 이해할 기회를 갖게 됩니다.",
    "지금 바로 <Michelin de 한양>을 플레이 해보세요."
  ],
];

  const titles = [
    "[ 조선 시대의 맛을 재현하다: 전통 한식의 여정에 오신 것을 환영합니다 ]",
    "[ 하루의 시작: 채집과 사냥 ]",
    "[ 요리의 마법: 전통 한식 만들기 ]",
    "[ 천하제일요리대회: 궁중 요리사를 향한 도전 ]",
    "[ 웹 게임으로 전통 한식 탐험하기 ]",

  ];

  const handleNext = () => {
    const nextTextIndex = textIndex + 1;
  
    if (nextTextIndex < textContent[section].length) {
      setTextIndex(nextTextIndex);
      setAccumulatedTexts([...accumulatedTexts, textContent[section][textIndex]]);
    } else if (section + 1 < textContent.length) {
      setSection(section + 1);
      setTextIndex(0);
      setAccumulatedTexts([]);
    }
  };

  const getBackgroundImage = () => {
    switch (section) {
      case 0: return GameBackground4;
      case 1: return GameBackground2;
      case 2: return GameBackground3;
      case 4: return GameBackground5;
      default: return GameBackground;
    }
  };

  return (
    <div 
      className="game-intro-container" 
      onClick={handleNext} 
      style={{ backgroundImage: `url(${getBackgroundImage()})` }}
    >
      <img src={ContentFrame} alt="Frame" className="box-frame"/>
      <img src={ContentFrame2} alt="Content Frame" className="content-frame-2"/>
      <h1><img src={GameName} alt="Game Name" style={{ marginTop: '-20px', width: '90%' }}/></h1>
      <h2 className='title'>{titles[section]}</h2>
      <div className="text-box">
        {accumulatedTexts.map((text, index) => (
          <p key={index}>{text}</p>
        ))}
        <p>
          {section === textContent.length - 1 && textIndex === textContent[section].length - 1 ? (
            <span className="highlight">{textContent[section][textIndex]}</span>
          ) : (
            textContent[section][textIndex]
          )}
        </p>
      </div>
    </div>
  );
};

export default GameIntro;


2. 게임 가이드

  • 여기는 게임 조작 키와 진행 방식을 gif와 css를 통해 구분해서 보여주었다.

  • 탭 네비게이션을 사용하여 주막, 사냥터, 장터 섹션 간에 전환을 할 수 있게 하였다.


3. 커뮤니티

  • 사용자가 게시글을 작성하고, 수정하고, 삭제할 수 있으며, 게시글에 댓글을 달 수 있는 기능을 구현하였다.
  • Community 컴포넌트는 게시글 목록을 표시하고,
  • PostDetails 컴포넌트는 개별 게시글의 상세 정보를 보여준다.
  • 사용자 인증을 위해 쿠키를 사용하였다.(GetCookie)

4. 랭킹

  • 게임 내 랭킹을 보여주는 페이지를 구현하였다. 사용자들은 특정 퀘스트 단계에 따라서 나눴으며 해당 퀘스트 완료에 따라 칭호가 달라지고 탭바를 통해 본인 퀘스트 완성도에 따른 랭킹 확인이 가능하다.

  • axios를 사용해서 서버로부터 랭킹 데이터를 전부 get하고, 각 퀘스트 단계별 데이터를 가져오며, 이를 하나의 배열로 결합한다.

  • 탭 변경:

각 탭을 클릭하여 특정 퀘스트 단계의 랭킹을 필터링하여 표시한다.

const questTitles = ['견습생', '초급요리사', '중급요리사', '고급요리사', '궁중요리사'];

const Ranking = () => {
  const [rankings, setRankings] = useState<RankingData[]>([]);
  const [selectedTab, setSelectedTab] = useState(0);

  const fetchRankings = async () => {
    try {
      let combinedRankings: RankingData[] = [];
      for (let i = 4; i >= 0; i--) {
        const response = await axios.get<RankingData[]>(`${process.env.REACT_APP_API}/users/questSuccess/${i}`);
        combinedRankings = [...combinedRankings, ...response.data];
      }
      combinedRankings.sort((a, b) => b.lv - a.lv);
      setRankings(combinedRankings);
    } catch (error) {
      console.error('Failed to fetch rankings:', error);
    }
  };

  useEffect(() => {
    fetchRankings();
  }, []);

  const filteredRankings = rankings.filter(ranking => ranking.questSuccess === selectedTab);

  return (
    <div className="ranking-container">
      <h1>게임 랭킹</h1>
      <div className="rankingtabs" style={{ position: 'relative', top: '-20px', zIndex: 2 }}>
        {questTitles.map((title, index) => (
          <button
            key={index}
            className={selectedTab === index ? 'active' : ''}
            onClick={() => setSelectedTab(index)}
          >
            {title}
          </button>
        ))}
      </div>
      <div className="ranking-inner-container">
        {filteredRankings.map((ranking, index) => (
          <div key={index} className="ranking-item">
            <div className="rank">{index + 1}등</div>
            <div className="name">{ranking.username}</div>
            <div className="score-bar">레벨: {ranking.lv}</div>
            <div className="score-bar">퀘스트 완료: {ranking.questSuccess}</div>
          </div>
        ))}
      </div>
    </div>
  );
};

5. 고객지원

  • 사용자는 FAQ를 통해 자주 묻는 질문을 확인하거나 1:1 문의를 보낼 수 있도록 하였다.
  1. FAQ 섹션:
    자주 묻는 질문을 펼치고 닫을 수 있는 아코디언 형식으로 구현하였다.
  2. 1:1 문의 섹션:
    사용자는 이름, 이메일, 문의 내용을 입력하여 문의를 보낼 수 있다. 문의 제출 후 서버로 POST 요청을 보내 문의 내용을 전송한다.


  • 거진 한달이 지난 후 하는 개발 회고지만 코드를 짰던 부분들이 아직도 기억이 생생하다..
  • 확실히 UNITY에서 로직 짜는 부분이 어렵긴 했지만 제일 재밌었고, 이후에도 게임 로직을 짜서 재밌는 게임 개발을 해보고 싶다.
  • 힘들고, 일도 정말 많았지만, 해당 프로젝트로 우수상 선정이 되어서 대표 발표자로 발표도 진행하였다.

진짜 끝!

profile
새싹 BJY

0개의 댓글