프로젝트 개요Project Overview
등산 중 발생한 산사태를 피해 하산하는 1인칭 3D 액션 레이싱 게임이다. 이름은 Run Away From Stones.
Run Away From Stones is a first-person 3D action racing game where you sprint down a mountain trail while a landslide tumbles down behind you.
| 항목 | 내용 |
|---|---|
| 시기 | 2022년 4월 |
| 엔진 | Unity 2021.2.17f1 |
| 기여도 | 100% (1인 개발) |
| 장르 | 1인칭 3D 액션 레이싱 |
| 결과 | 실력 증명 용도로 제작. 이후 고도화해 전시 시연까지 진행 |
| Item | Details |
|---|---|
| Date | April 2022 |
| Engine | Unity 2021.2.17f1 |
| Role | 100% (Solo development) |
| Genre | First-person 3D action runner |
| Outcome | Built as a skill-check prototype, later polished with custom art for an exhibition demo |
GTA5의 하산 레이스 모드에서 영감을 받았다. 떨어지는 장애물을 등 뒤에 두고 달려 내려가는 그 구도만 떼어내 독립적인 게임으로 구성해 보고 싶었다.
The idea came from custom downhill race modes in GTA V. I wanted to isolate that core setup—running downhill with falling debris right at your back—and turn it into a standalone game.

핵심 시스템 세 가지Three Core Systems
- 1인칭 조작계 — 몰입감과 생동감을 위해 1인칭 시점을 골랐다. W/A/S/D 이동에 달리기와 미끄러지기를 붙였다.
- 랜덤 낙하 장애물 — 산 위에서 통나무, 바위, 돌덩이가 무작위로 굴러떨어진다.
- 맵과 환경 분기 — 일반 산과 눈 덮인 산 두 가지 환경을 만들었고, 눈 지형에서는 미끄러지는 물리 효과를 넣었다.
- First-person controls — I chose a first-person camera to make the downhill speed feel immediate, pairing W/A/S/D movement with sprinting and sliding.
- Random falling obstacles — Logs, boulders, and rocks roll down randomly from the top of the slope.
- Multiple environments — I built both a regular mountain trail and a snow-covered slope, adding slippery surface physics on the snow stage.
낙하물 스폰Obstacle Spawning
장애물은 미리 배치하지 않고 일정 주기로 생성한다. GameManager 쪽 코드다.
Instead of pre-placing hazards, GameManager spawns them on a fixed interval.
void FixedUpdate()
{
if (isNakHa)
{
isNakHa = false;
// 무작위 낙하물을 무작위 스폰 위치에 생성
Instantiate(nakHa[Random.Range(0, nakHa.Count)],
pos[Random.Range(0, pos.Count)].position,
Quaternion.identity);
StartCoroutine(waitForit());
}
}
IEnumerator waitForit()
{
yield return new WaitForSeconds(2f);
isNakHa = true;
}
void FixedUpdate()
{
if (isNakHa)
{
isNakHa = false;
// Spawn a random falling obstacle at a random spawn point
Instantiate(nakHa[Random.Range(0, nakHa.Count)],
pos[Random.Range(0, pos.Count)].position,
Quaternion.identity);
StartCoroutine(waitForit());
}
}
IEnumerator waitForit()
{
yield return new WaitForSeconds(2f);
isNakHa = true;
}
낙하물 프리팹 목록(nakHa)과 스폰 지점 목록(pos)에서 각각 하나씩 무작위로 뽑아 그 자리에 생성한다. 생성한 뒤 isNakHa 를 내리고, 코루틴이 2초 뒤에 다시 올린다.
It picks one prefab at random from nakHa and one transform from pos, instantiates the obstacle, flips isNakHa to false, and starts a coroutine that sets it back to true two seconds later.
이 코드를 지금 읽으면Looking Back at This Code Now
- 플래그로 타이머를 구현했다.
FixedUpdate가 매 물리 프레임 돌면서isNakHa만 확인하고, 실제 대기는 코루틴이 한다. 결과는 2초 주기지만 경로가 두 군데로 나뉘어 있다. 코루틴 하나 안에서while (true) { spawn; yield return new WaitForSeconds(2f); }로 도는 편이 읽기 쉽다. FixedUpdate에 있을 이유가 없다. 스폰은 물리 연산과 무관한데 물리 주기에 묶여 있다.Time.fixedDeltaTime설정을 바꾸면 함께 영향을 받는다.- 주기가 2초 고정이다. 시작부터 끝까지 난이도가 같다. 경과 시간에 따라 간격을 줄이면 자연스러운 난이도 곡선이 생긴다.
- 정리 코드는 이 조각에 없다. 생성된 낙하물을 언제 없애는지는 이 코드만으로는 알 수 없다. 화면 밖으로 나간 것을 지우거나 오브젝트 풀로 돌려쓰는 처리가 없다면, 플레이가 길어질수록 씬의 오브젝트가 계속 늘어난다.
- A timer built out of a boolean flag.
FixedUpdatechecksisNakHaevery physics tick while the coroutine does the actual waiting. The result is a 2-second loop, but the control flow is split across two methods. A single coroutine runningwhile (true) { spawn; yield return new WaitForSeconds(2f); }would be much easier to read. - No reason to be in
FixedUpdate. Spawning prefabs has nothing to do with physics steps, yet it is tied to the physics loop and affected byTime.fixedDeltaTime. - Hardcoded 2-second interval. The difficulty stays flat from start to finish. Shrinking the interval over elapsed time would create a natural difficulty curve.
- No cleanup shown in this snippet. If spawned rocks are not destroyed off-screen or returned to an object pool, active objects in the scene will keep piling up the longer a run lasts.
1인칭 캐릭터 제어First-Person Character Control
이동은 CharacterController 로 처리했다. 마우스 입력으로 시점을 돌리고, 키보드 입력을 조합해 이동·점프·중력을 계산한다.
Movement uses Unity's CharacterController. Mouse input rotates the view horizontally, while keyboard input drives movement, jumping, and gravity.
void Update()
{
float mouseX = Input.GetAxisRaw("Mouse X") * mouseDps;
transform.Rotate(Vector3.up * mouseX);
// 걷기 애니메이션 제어
if (Mathf.Abs(Input.GetAxisRaw("Horizontal")) > 0 || Mathf.Abs(Input.GetAxisRaw("Vertical")) > 0)
anim.SetBool("Walk", true);
else
anim.SetBool("Walk", false);
// 지면에 닿아있을 때 이동 및 점프 계산
if (controller.isGrounded)
{
moveDirection = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
moveDirection = transform.TransformDirection(moveDirection);
moveDirection *= speed;
if (Input.GetButton("Jump"))
moveDirection.y = jumpSpeed;
}
// 중력 적용 후 이동
moveDirection.y -= gravity * Time.deltaTime;
controller.Move(moveDirection * Time.deltaTime);
}
void Update()
{
float mouseX = Input.GetAxisRaw("Mouse X") * mouseDps;
transform.Rotate(Vector3.up * mouseX);
// Walk animation control
if (Mathf.Abs(Input.GetAxisRaw("Horizontal")) > 0 || Mathf.Abs(Input.GetAxisRaw("Vertical")) > 0)
anim.SetBool("Walk", true);
else
anim.SetBool("Walk", false);
// Calculate movement and jump while grounded
if (controller.isGrounded)
{
moveDirection = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
moveDirection = transform.TransformDirection(moveDirection);
moveDirection *= speed;
if (Input.GetButton("Jump"))
moveDirection.y = jumpSpeed;
}
// Apply gravity and move
moveDirection.y -= gravity * Time.deltaTime;
controller.Move(moveDirection * Time.deltaTime);
}
y 에 계속 더해진다. 점프 중 방향 전환이 안 되는 대신 뛰어내린 순간의 관성이 그대로 살아 있어서, 경사면을 따라 내려가는 게임에는 오히려 맞는 감각이 나온다.y down. You cannot steer mid-air, which keeps your takeoff momentum intact—and for a game about hurtling down a steep slope, that inertia actually felt right.이 코드를 지금 읽으면Looking Back at This Code Now
GetAxisRaw와GetAxis를 섞어 쓴다. 애니메이션 판정에는GetAxisRaw, 이동에는GetAxis다.GetAxis는 값이 서서히 붙고GetAxisRaw는 즉시 −1/0/1 로 튄다. 입력을 뗀 직후GetAxis는 아직 0이 아닌데GetAxisRaw는 이미 0이라, 애니메이션이 먼저 멈추고 이동이 조금 더 미끄러진다.anim.SetBool("Walk", ...)를 매 프레임 문자열로 호출한다.Animator.StringToHash("Walk")로 한 번만 변환해 두고 int 버전을 쓰는 편이 낫다.- 시점 회전이 좌우뿐이다. 이 조각에는 상하 회전이 없다. 카메라 쪽에서 따로 처리했을 가능성이 크다.
- 중력을
Update에서Time.deltaTime으로 누적한다. 동작하지만 물리 계산은FixedUpdate쪽이 프레임 레이트에 덜 흔들린다. 앞의 스폰 코드가FixedUpdate에 있는 것과 정확히 반대다.
- Mixing
GetAxisRawandGetAxis. Animation checks useGetAxisRaw, while movement usesGetAxis. BecauseGetAxissmooths toward zero andGetAxisRawsnaps immediately to -1/0/1, releasing a key stops the walk animation while the character still slides slightly. - Calling
anim.SetBool("Walk", ...)with a string every frame. Caching the ID once viaAnimator.StringToHash("Walk")avoids repeated string hashing. - Horizontal rotation only. Vertical pitch is not in this script; it was likely handled separately on the camera object.
- Accumulating gravity in
UpdatewithTime.deltaTime. It works, though physics-like integration is more stable inFixedUpdate—ironically the exact opposite of the spawn code above.
리소스를 다시 만든 이야기Remaking All the 3D Assets
제출 기한을 맞춰야 해서 처음에는 무료 에셋을 가져다 기초 형태만 잡았다. 이후 프로젝트를 고도화하면서 필요한 3D 오브젝트와 텍스처를 전부 직접 모델링해 갈아 끼웠다. 아트 톤이 제각각인 문제를 그렇게 정리했다.
Because the initial submission deadline was tight, I grabbed free assets just to block out the prototype. Later, when polishing the project for exhibition, I modeled and textured all the 3D objects myself and swapped out the placeholders. That solved the mismatched art style.





정리Wrap-Up
짧은 제출 기한이라는 압박 속에서도 필수 요구사항이 아니었던 기획서까지 직접 쓰고, 코어 게임플레이를 온전히 시연할 수 있는 프로토타입을 완성했다.
Even under a tight deadline, I wrote a full design document that was not even required and finished a playable prototype that demonstrated the core loop.
그리고 거기서 멈추지 않았다. 이후 에셋을 교체하고 게임 구조를 다듬으면서 프로토타입을 전시 가능한 수준으로 끌어올리는 경험을 얻었다. 결과적으로 여러 사람 앞에서 시연하는 데까지 갔다.
And I did not leave it as a rough prototype. Replacing the art and tightening the game flow gave me hands-on experience taking a quick prototype up to exhibition quality, eventually demoing it in front of a live audience.
지금 코드를 다시 읽으면 FixedUpdate 와 Update 의 역할이 뒤바뀌어 있다거나, 스폰 주기가 고정이라거나 하는 것들이 눈에 들어온다. 당시에는 동작하는 게 우선이었고, 그 판단 자체는 틀리지 않았다고 생각한다. 다만 그때 넘어간 것들이 목록으로 남아 있다는 것도 사실이다.
Reading the code today, things like swapping FixedUpdate and Update responsibilities or hardcoding the 2-second spawn timer jump right out. At the time, getting it working came first, and that was the right call for the deadline—even if it left a clear list of things I would write differently now.