프로젝트 개요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 액션 레이싱
결과실력 증명 용도로 제작. 이후 고도화해 전시 시연까지 진행
Swipe horizontally to view the full table
ItemDetails
DateApril 2022
EngineUnity 2021.2.17f1
Role100% (Solo development)
GenreFirst-person 3D action runner
OutcomeBuilt 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.

Unity 에디터에서 본 경사면 맵. 녹색 슬로프 위에 나무와 바위가 배치되어 있다
에디터에서 본 맵 전경. 경사면을 따라 나무와 바위를 배치했다.Full map view in the Unity Editor, with trees and rocks placed along the slope.

핵심 시스템 세 가지Three Core Systems

낙하물 스폰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

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);
}
isGrounded 안에서만 방향을 갱신하는 구조이동 벡터를 땅에 닿아 있을 때만 새로 만든다. 공중에 뜬 동안에는 직전에 정해진 방향과 속도가 그대로 유지되고, 중력만 y 에 계속 더해진다. 점프 중 방향 전환이 안 되는 대신 뛰어내린 순간의 관성이 그대로 살아 있어서, 경사면을 따라 내려가는 게임에는 오히려 맞는 감각이 나온다.
Updating Direction Only Inside isGroundedThe movement vector is recalculated only while the player is touching the ground. While airborne, the horizontal direction and speed from takeoff are preserved while gravity pulls 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

리소스를 다시 만든 이야기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.

박스 형태로 단순하게 모델링된 로우폴리 캐릭터
플레이어 캐릭터. 면을 최소한으로 줄인 로우폴리 형태로 만들었다.Player character modeled in a minimal low-poly blocky style.
직육면체를 쌓아 만든 통나무 더미 3D 모델
낙하물 중 하나인 통나무 더미.A pile of logs, one of the falling hazards.
정육면체를 계단식으로 쌓아 만든 바위 3D 모델
바위. 큐브를 계단처럼 쌓아 덩어리를 만들었다.A boulder formed by stacking cubes in stepped layers.
삼단 박스와 기둥으로 이루어진 로우폴리 나무 모델
배경에 세울 나무.A low-poly tree placed along the course.
계단 형태의 단차가 들어간 경사면 지형 3D 모델
코스가 되는 경사면 지형. 계단 형태의 단차를 넣어 내려가는 길을 만들었다.The downhill course terrain, using stepped drops to shape the descent.
직접 만들면서 얻은 것로우폴리로 통일하면 모델링 시간이 줄 뿐 아니라 에셋마다 톤이 어긋나는 문제가 애초에 생기지 않는다. 무료 에셋을 여러 곳에서 모으면 폴리곤 밀도와 텍스처 해상도가 제각각이라, 한 화면에 놓았을 때 그것부터 눈에 걸린다.
What Modeling Everything Myself SolvedSticking to a unified low-poly style not only cut modeling time, it eliminated art-style clashes from the start. When you kitbash free assets from different packs, polygon density and texture resolution vary wildly, and that mismatch is the first thing people notice on screen.

정리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.

당시 기록만들면서 남긴 글이 개인 블로그에 있다. 기록 보기
Original NotesMy notes from when I built this are on my personal blog: Read the original post