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

해당 판넬(유니티 판넬 2D object 생성)을 클릭했을때, 모달 창이 띄우는것 처럼 표현하는 것은 웹에서의 구현방식과는 많이 달랐는다. 요약하자면,
UI 캔버스 설정
팝업 패널 생성
팝업 내용 추가
스크립트로 팝업 제어
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);
}
}
Canvas에 PopupManager 스크립트를 추가해서 연결
버튼에 함수 연결:
팝업을 닫는 버튼의 Button 컴포넌트에서 On Click() 이벤트를 추가
PopupManager를 드래그하여 추가한 후, HidePopup 함수를 선택
이런식으로, 해당 팝업에 들어갈 버튼 요소 (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초 정도 후에 움직이며 다시 스폰 장소로 이동하며 삭제
단계별로 간략하게 소개를 하자면,



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;
}
}
}
}
리스트로 저장된 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);
}
}
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));
}
}
1. 안정성 확보
- null 검사는 프로그램이 예상치 못한 상황에서 비정상적으로 종료되지 않도록 해야한다.
예를 들어, targets 리스트나 agent 객체가 null인 경우에 해당 리스트나 객체에 접근하려고 시도하면
NullReferenceException이 발생할 수 있다.
2. 예외 처리 및 디버깅 용이
이 정도의 이유 때문이다.
public void InitializeAgent() {
agent = GetComponent<NavMeshAgent>();
if (agent != null) {
agent.updateRotation = false;
agent.updateUpAxis = false;
MoveToSitArea();
}
}
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;
}
}
}
}
private void OnTriggerStay2D(Collider2D collision) {
if (!flag && Input.GetKey(KeyCode.Z) && thePlayer.GetAnimatorFloat("DirX") == -1f) {
flag = true;
StartCoroutine(EventCoroutine());
}
}
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;
}
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;
}
}
}
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 값과 함께 발생시킨다.
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);
}
}
조금 후회되는 부분은 CustomerLogic을 한 스크립트 안에 좀 때려박아서 디버깅 하는데 굉장히 힘들었다는 점......
구현 기능은 크게
- 게임 소개
- 게임 가이드
- 커뮤니티
- 랭킹
- 고객 지원
사용자가 페이지를 클릭할 때마다 호출된다.
현재 텍스트의 인덱스를 증가시켜 다음 텍스트를 표시함
현재 섹션의 모든 텍스트를 표시한 경우, 다음 섹션으로 넘어간다.
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;

여기는 게임 조작 키와 진행 방식을 gif와 css를 통해 구분해서 보여주었다.
탭 네비게이션을 사용하여 주막, 사냥터, 장터 섹션 간에 전환을 할 수 있게 하였다.


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



진짜 끝!