2026.03.03 ~ 2026.03.23 (총 3주)
Unity를 이용한 UI를 사용하지 않은 3D 방 탈출 게임 제작
스토리: 시작은 어두운 서재, 낯선 사람과 나와 생성된다. 조력자는 환자복과 얼굴이 안보이게 머리에 붕대를 둘둘 두르고 있다. 현재 지상에는 핵 전쟁으로 멸망 했다. 나는 지상에서 야구 선수였다.
엔딩: 방사능 체크기는 기믹을 모두 수행하고 나는 다음 기믹을 수행하려고 가고 있는데 갑자기 삐삐삐 소리를 내면서 지상과 가까워 진듯한 느낌을 주며 긴장감 조성. 이 소리의 발원지는 박스안으로 설정. 어떤 목표에 닿으면 씬을 바꾸면서 지상으로 올라오면 지상이 무너져 있는 야구장이 나오고 마무리
게임 진행: 기믹을 1번 씩 클리어 할 때마다 나의 기억이 되는 단서를 준다. (야구공, 야구 베트, 방사능 수치 체크기 등등)
이 단서들은 조력자의 박스에 담겨서 이동 됨.
맵 형식: 지하는 크게 하나의 맵으로 구성하고 여기에 구역을 나눠서 기믹을 설정. 일단 기본적으로 돌 바닥, 각 방마다 벽들은 돌로 막혀있음, 돌 벽 중앙 아래에 사람이 다닐만한 철문이 있음. 기믹을 깨면 철문이 열림.
맵과 등장인물 컨셉: 여기는 깊은 지하 어딘가. 나의 상태는 기억을 잃음. 낯선 사람의 정체는 조력자이며 원래 있던 사람. 나와 함께 지하를 탈출해야함.
조력자는 내 뒤를 계속 따라 다니다가 내가 기믹 하나를 달성하면 다음 기믹으로 나를 인도하는 역할. 조력자가 시작하면 박스를 가지고 이동함. 이동할 때는 나의 뒤에서 일정 거리 유지하면서 따라옴. 기믹 달성하면 시네머신이 조력자 시점으로 바뀌고 목표지점 까지 이동 후에 플레이어에게 여기로 와라는 제스처를 함. 조력자한테 가까이 다가가서 클릭하면 박스에 있는 내용물을 보여줌.
조작 및 움직임: 나는 wasd로 앞뒤옆으로 이동 할 수 있고 마우스를 움직여 회전 할 수 있고 에임을 가지고 있고 에임은 기믹을 클릭하는 역할(ex. 빛나는 책 클릭)
맵 기믹
1구역 서재: 4개의 서재가 왼쪽부터 1, 2, 3, 4 번의 순서를 가짐. 각각의 서재에는 빛나는 책들이 있다. 4번째 서재에는 책이 비어 있는 3개의 빈 홈이 있고 빈 홈에 빛나는 책들을 꽂으면 '오래된 야구공'을 얻을 수 있다.
2구역 기계실: 벽면에 스트라이크 존 판넬, 주변에 bool 타입 레버가 있다. 레버를 하나 씩 밀 때 마다 판넬 위의 빛이 움직인다. 빛을 정확히 중앙에 모으면 스트라이크존 판넬에서 '녹슨 야구 배트'가 나옴.
3구역 훈련장: 앞에 얻었던 '오래된 야구공'이 조력자의 박스에서 소리와 함께 떨어진다. 야구공을 클릭하면 플레이어가 공을 집는다. 동서남북으로 1루, 2루, 3루, 홈 모양으로 생긴 플랫폼들이 배치 되어 있고 공으로 맞추면 맞춘곳은 split effect처럼 분열한다. 4곳을 전부 맞추어서 플랫폼들을 전부 없애면 마지막에 없앤곳에서는 분열 할 때 분열체로 글러브가 떨어진다. 그리고 공을 상자에 넣으라고 조력자가 내 앞에서 박스를 내민다.
4구역: 구역에 들어서면 야구 배트가 소리와 함께 상자에서 떨어지고 플레이어는 야구 배트를 집을 수 있다. 배트를 집으면 다른 생존자(적)들이 8마리 스폰 되고 1마리는 키가 남들보다 크다(보스몹). 야구 배트로 적을 전부 해치워야 하고 보스몹을 해치우면 '방사능 체크기'를 떨군다. 그 후 조력자는 플레이어 앞에 와서 박스를 내민다. 물건들을 담는다. 체력이 0이 되면 체크포인트에서 부활.
5구역: 5구역을 열면 살짝 먼곳에 밝은 빛이 비치고 그 쪽으로 쭉 가면 햇빛을 처음 받듯이 쩅해지면서 씬이 바뀐다.
퍼즐 세부 사항
공통 필수 구현: 레이케스트를 이용한 상호작용
1구역: 포스트 프로세싱 값 조절로 플레이어가 서서히 눈을 뜨는 듯한 연출 구현(가능하면), 책을 배열이나 리스트로 관리함. 플레이어가 책을 클릭하면 책 오브젝트를 SetActive(false) 하고 4번째 서재의 빈 홈을 클릭 할 때 마다 미리 배치해 둔 책들을 SetActive(true)로 켜서 책을 꽂는 형식. 조건이 만족 되면 야구공을 생성.
2구역: 레버를 상호 작용 할 때 마다 값을 빛을 이동시키고, 조건에 따라 타겟 위치를 설정하고 Lerp를 이용해서 빛 오브젝트를 해당 위치로 부드럽게 이동시킴. 빛의 현재 위치와 타겟 위치의 거리를 Distance로 계산해서 특정 수치가 되면 야구 배트를 활성화시킴.
3구역: 야구공을 집으면 AddForce를 주어 공을 날림. OncollisionEnter로 야구공과 충돌 감지. 분열 이펙트 발동. 4번 째 플랫폼 파괴 시 글러브 생성.
4구역: 배트 스윙 애니메이션 있어야함. 야구 배트로 적들과 전투 로직 구성. NavMesh 사용해서 이동 로직 구현. hp가 0이 되면 체크포인트에서 다시 부활.
5구역: 플레이어와 탈출구(빛) 사이의 거리를 지속적으로 계산. 이 값을 이용해서 방사능 체크기 삐삐 소리 재생속도를 높이거나 화면이 하얘지는 효과 구현. 거리가 되면 LoadScene으로 엔딩 씬 호출.
필요한 연출
1구역: 플레이어가 눈이 서서히 떠지는 연출(가능하다면)
2구역: 레버를 조작 할 때마다 판넬쪽으로 카메라 이동
3구역: 입장 시 조력자의 가방에서 야구공 떨어지는 장면 연출, 기믹 완료 후 조력자가 박스를 내미는 연출
4구역: 입장 시 조력자의 가방에서 배트 떨어지는 장면 연출, 적 스폰 되는 곳 카메라 이동 연출, 기믹 완료 후 조력자가 박스를 내미는 연출
1주차 목표: 유니티 에디터 기본 오브젝트를 이용해서 퍼즐 구현
2주차 목표: 에셋 적용, 맵 디자인, 퍼즐 버그 수정
3주차 목표: 퍼즐 / 디자인 / 버그 수정, 프로젝트 소개 영상 제작
예시)



// 마우스 커서 고정 및 숨김 처리
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
public interface IInteractable
{
// 클릭했을 때 실행될 기능의 이름만 정의
void OnInteract();
void OnFocus(); // 레이가 닿았을 때
void OnLoseFocus(); // 레이가 벗어났을 때
}
2.1. 구역 별 퍼즐을 클리어 한 후에 나오는 클리어 보상 오브젝트에 실제로 인터페이스를 상속 받고
시선이 닿을 때( OnFocus() ), 벗어날 때( OnLoseFocus() ), 실제 상호작용( OnInteract() ) 후에
각각의 함수에 색이 바뀌고, 연출과 사운드 재생이 되도록 구현했습니다.
// 클리어 보상 아이템을 주웠을 때 스크립트, 만들어 놓은 인터페이스를 상속받음.
public class PickupItem : MonoBehaviour, IInteractable
------------------------------------------------------------------------
[Header("아이템 정보")]
public string itemName; // 아이템 이름 (Baseball 또는 Bat)
public bool forceToBox = false; // 상자로 강제 전송 여부
[Header("연결 설정")]
public HelperBox helperBox; // 조력자 상자 연결
public DoorController doorToOpen; // 획득 시 열리는 문
[Header("컷씬 매니저")]
public Area1_Library area1Manager;
public Area2_MachineRoom area2Manager;
[Header("사운드 설정")]
public AudioClip zipSound; // 가방 지퍼 소리
[Header("시각적 피드백")]
private Material[] itemMaterials; // 아이템 마테리얼 배열
private Color defaultColor = Color.blue * 2.0f; // 기본 에미션 컬러
private Color focusColor = Color.yellow * 2.5f; // 주시 시 에미션 컬러
private PlayerThrow playerThrow; // 플레이어 투척 스크립트 참조
void Start()
{
playerThrow = FindAnyObjectByType<PlayerThrow>();
// 자식 오브젝트의 모든 렌더러 컴포넌트 추출
Renderer[] renderers = GetComponentsInChildren<Renderer>();
if (renderers.Length > 0)
{
itemMaterials = new Material[renderers.Length];
// 모든 렌더러의 마테리얼에 에미션 설정 적용
for (int i = 0; i < renderers.Length; i++)
{
itemMaterials[i] = renderers[i].material;
itemMaterials[i].EnableKeyword("_EMISSION");
itemMaterials[i].SetColor("_EmissionColor", defaultColor);
}
}
}
public void OnFocus()
{
// 시선이 닿을 때 노란색 에미션 적용
if (itemMaterials == null) return;
foreach (Material mat in itemMaterials)
{
if (mat != null) mat.SetColor("_EmissionColor", focusColor);
}
}
public void OnLoseFocus()
{
// 시선이 벗어날 때 파란색 에미션 복구
if (itemMaterials == null) return;
foreach (Material mat in itemMaterials)
{
if (mat != null) mat.SetColor("_EmissionColor", defaultColor);
}
}
public void OnInteract()
{
// 1구역 매니저가 연결되어 있다면 컷씬 재생
if (area1Manager != null) area1Manager.StartDoorCutscene();
// 2구역
if (area2Manager != null) area2Manager.StartBatCutscene();
// 연결된 문이 있을 경우 개방
if (doorToOpen != null) doorToOpen.OpenDoor();
// 야구공이고 플레이어가 들고 있지 않으며 상자 전송 모드가 아닐 때 즉시 장착
if (itemName == "Baseball" && playerThrow != null && !playerThrow.isHoldingBall && !forceToBox)
{
playerThrow.EquipBall();
}
else
{
// 그 외의 경우 조력자 상자에 아이템 추가
if (helperBox != null) helperBox.AddItem(itemName);
}
// 지퍼 사운드 재생 (오브젝트가 꺼져도 재생 유지)
if (zipSound != null)
{
AudioSource.PlayClipAtPoint(zipSound, transform.position);
}
OnLoseFocus();
gameObject.SetActive(false); // 오브젝트 비활성화
}
실제 인게임
물체가 발광 하고 있음.

플레이어의 시선이 닿음. Onfocus()
using UnityEngine;
using UnityEngine.AI;
public class HelperAI : MonoBehaviour
{
[Header("추적 대상")]
public Transform player;
[Header("유지할 거리")]
public float followDistance = 2.5f;
[Header("상태")]
public bool isCutsceneMode = false;
private NavMeshAgent agent;
private Animator anim;
void Start()
{
agent = GetComponent<NavMeshAgent>();
anim = GetComponentInChildren<Animator>();
}
void Update()
{
if (agent == null) return;
// 구역 진입 연출 여부에 따른 정지 거리 제어
if (isCutsceneMode)
{
agent.stoppingDistance = 0f;
}
else
{
agent.stoppingDistance = followDistance;
if (player != null)
{
agent.SetDestination(player.position);
// 정지 상태 시 플레이어 주시 회전
if (agent.velocity.sqrMagnitude < 0.1f)
{
Vector3 lookPos = player.position - transform.position;
lookPos.y = 0;
if (lookPos != Vector3.zero)
{
Quaternion rotation = Quaternion.LookRotation(lookPos);
transform.rotation = Quaternion.Slerp(transform.rotation, rotation, Time.deltaTime * 5f);
}
}
}
}
if (anim != null)
{
// 현재 이동 속도를 최고 속도로 나누어서 0 ~ 1 사이의 숫자로 만듦
float currentSpeed = agent.velocity.magnitude / agent.speed;
// Speed 파라미터
anim.SetFloat("Speed", currentSpeed, 0.1f, Time.deltaTime);
}
}
// 상자 상호작용 활성화
public void OfferBox()
{
Debug.Log("조력자 상자 개방 상태 진입");
// 가방을 보여줄 때는 NavMeshAgent의 '자동 회전' 끔
if (agent != null)
{
agent.updateRotation = false; // 내비게이션이 마음대로 회전하는 것 차단
agent.ResetPath(); // 미세하게 남아있는 이동 목표치 초기화 (꿈틀거림 방지)
}
// 가방 스크립트 시각적 피드백 시작
HelperBox box = FindAnyObjectByType<HelperBox>();
if (box != null) box.StartOfferMode();
}
// 상호작용 종료 및 추적 재개
public void StopOffering()
{
isCutsceneMode = false;
// 다시 플레이어를 따라다녀야 하므로 자동 회전을 킴
if (agent != null)
{
agent.updateRotation = true;
}
// 가방 뚜껑 닫기 및 피드백 종료
HelperBox box = FindAnyObjectByType<HelperBox>();
if (box != null) box.CloseLid();
Debug.Log("조력자 추적 모드 복귀");
}
}
using UnityEngine;
public class HelperBox : MonoBehaviour, IInteractable
{
public bool isLooted = false; // 아이템 획득 여부
public bool isLocked = false; // 상자 잠금 상태
public string allowedItem; // 획득 가능 아이템 이름
private bool isOfferMode = false; // 상자 제시 상태
[Header("구역 설정")]
public bool isArea3UnlockBox = false; // 3구역 전용 투척 해제 가방 여부
public bool isArea4UnlockBox = false;
[Header("4구역 전투 연결")]
public Area4_Manager area4Manager; // 4구역 몬스터 스폰 신호용
[Header("사운드")]
public AudioSource audioSource;
public AudioClip zipSound;
[Header("시각적 피드백")]
private Material boxMaterial;
private Color defaultColor = Color.black; // 기본 색상
private Color offerColor = Color.blue * 2.0f; // 제시 상태 색상
private Color focusColor = Color.yellow * 2.5f; // 주시 상태 색상
private PlayerThrow playerThrow;
void Start()
{
playerThrow = FindAnyObjectByType<PlayerThrow>();
Renderer rend = GetComponentInChildren<Renderer>();
if (rend != null)
{
boxMaterial = rend.material;
boxMaterial.EnableKeyword("_EMISSION");
boxMaterial.SetColor("_EmissionColor", defaultColor);
}
}
void OnMouseDown()
{
// 3구역/4구역 연출로 인해 isOfferMode가 true일 때만 클릭 가능
if (isOfferMode && !isLooted && !isLocked)
{
Debug.Log("가방을 직접 클릭함: 상호작용 시작");
OnInteract();
}
}
public void OnInteract()
{
if (isLocked || isLooted || !isOfferMode) return;
// 지퍼 소리 재생 (클릭하자마자 소리가 나도록 상단 배치)
if (audioSource != null && zipSound != null)
{
audioSource.PlayOneShot(zipSound);
}
if (allowedItem == "Baseball" && playerThrow != null)
{
if (isArea3UnlockBox)
{
playerThrow.UnlockThrow();
playerThrow.EquipBall();
isLooted = true;
}
else
{
Debug.Log("현재 구역 야구공 획득 불가");
return;
}
}
else if (allowedItem == "Bat")
{
// 배트도 4구역 허락이 떨어졌을 때만 꺼낼 수 있음
if (isArea4UnlockBox)
{
isLooted = true;
PlayerMelee melee = FindAnyObjectByType<PlayerMelee>();
if (melee != null) melee.EquipBat();
// 배트를 쥐는 순간 4구역 매니저에게 전투 시작 알림
if (area4Manager != null) area4Manager.StartBattle();
}
else
{
Debug.Log("현재 구역 배트 획득 불가");
return; // 가방 닫히지 않고 그대로 유지
}
}
OnLoseFocus();
StopOfferMode();
HelperAI ai = FindAnyObjectByType<HelperAI>();
if (ai != null) ai.StopOffering();
}
public void StartOfferMode()
{
isOfferMode = true;
isLocked = false;
if (boxMaterial != null) boxMaterial.SetColor("_EmissionColor", offerColor);
}
public void StopOfferMode()
{
isOfferMode = false;
if (boxMaterial != null) boxMaterial.SetColor("_EmissionColor", defaultColor);
}
public void OnFocus()
{
if (isOfferMode && boxMaterial != null)
{
boxMaterial.SetColor("_EmissionColor", focusColor);
}
}
public void OnLoseFocus()
{
if (isOfferMode && boxMaterial != null)
{
boxMaterial.SetColor("_EmissionColor", offerColor);
}
}
// 아이템 추가 및 상태 초기화
public void AddItem(string item)
{
allowedItem = item;
isLooted = false;
isLocked = false;
}
// 매니저 스크립트 호환용 함수
public void ShowItemInBox(string item)
{
AddItem(item);
StartOfferMode();
}
public void CloseLid()
{
StopOfferMode();
}
}
using UnityEngine;
using UnityEngine.AI;
public class DoorController : MonoBehaviour
{
[Header("문 열림 설정")]
public Vector3 openAngle = new Vector3(0, 90, 0); // 회전 목표 각도
public float openSpeed = 3f; // 문 열리는 속도
[Header("사운드 설정")]
public AudioClip openSound; // 문 열리는 소리
private bool isOpen = false;
private Quaternion startRot;
private Quaternion targetRot;
private NavMeshObstacle navObstacle;
void Start()
{
// 시작 시점 초기 회전값 저장
startRot = transform.localRotation;
// 초기 회전값 기준 목표 회전값 계산
targetRot = startRot * Quaternion.Euler(openAngle);
// 네비메시 장애물 컴포넌트 참조 저장
navObstacle = GetComponent<NavMeshObstacle>();
}
void Update()
{
// 문 개방 상태에서만 회전 로직 실행
if (isOpen)
{
// 목표 각도로 부드러운 회전 처리
transform.localRotation = Quaternion.Slerp(transform.localRotation, targetRot, Time.deltaTime * openSpeed);
// 회전이 거의 완료되면 업데이트 중단 처리 (최적화)
if (Quaternion.Angle(transform.localRotation, targetRot) < 0.1f)
{
transform.localRotation = targetRot;
isOpen = false;
}
}
}
public void OpenDoor()
{
if (isOpen) return;
isOpen = true;
// 사운드 재생 (오브젝트 위치에서)
if (openSound != null)
{
AudioSource.PlayClipAtPoint(openSound, transform.position);
}
// 네비메시 장애물 비활성화로 경로 개방
if (navObstacle != null)
{
navObstacle.enabled = false;
}
}
}
1~4구역 및 엔딩 및 프로젝트를 하면서 느낀점은 다음 포스팅에서 작성하도록 하겠습니다.