왜 세 번인가

클리커(방치형) 게임을 연습용으로 세 번 만들었다. 같은 장르를 반복한 이유는 단순하다. 코어 루프가 단순해서 그 위에 올릴 것을 바꿔 가며 시험하기 좋았다. 클릭하면 재화가 오르고, 시간이 지나도 오르고, 그 재화로 뭔가를 올린다. 이 뼈대는 세 번 다 같다.

엔진무엇을 연습했나
1번째Unity 2018.3.12f1유니티 기초 동작 원리와 클리커 장르의 기본 시스템
2번째Unity 2019.4.4f1UI 상호작용과 상태(버프/디버프)에 따른 로직 제어 — 골드메탈 유튜브 강좌를 따라가며 제작
3번째Unity 2018.3.14f1객체지향적인 코드 구조와 게임 상태 관리(FSM)

셋 다 1인 개발이고 전부 완성했다. 아래에 각각 무엇을 만들었고 코드가 어떻게 달라졌는지 적는다.

1번째 — 일단 돌아가게

오브젝트를 터치하면 골드를 얻고, 시간이 지나면 자동으로도 얻고, 그 골드로 능력치를 올린다. 여기에 데이터 저장과 광고 보상까지 붙였다. UI 그림은 그림판으로 직접 그려 썼다.

데이터를 어떻게 들고 있나

싱글톤 DataController 를 두고, 게임 데이터를 JSON으로 로컬에 저장·로드했다.

public void LoadGameData()
{
    string filePath = Application.persistentDataPath + gameDataProjectFilePath;

    if (File.Exists(filePath))
    {
        string dataAsJson = File.ReadAllText(filePath);
        _gameData = JsonUtility.FromJson<GameData>(dataAsJson);
    }
    else
    {
        _gameData = new GameData();
        _gameData.CollectGoldLevel = 1;
        _gameData.GoldPerSec = 1;
        _gameData.Gold = 0;
        _gameData.Health = 100;
        _gameData.Damage = 20;
        _gameData.Level = 1;
        _gameData.Exp = 0;
    }
}
왜 persistentDataPath 인가모바일에서 앱이 자유롭게 읽고 쓸 수 있는 경로는 정해져 있다. Application.persistentDataPath 는 플랫폼마다 다른 그 경로를 대신 골라 준다. 안드로이드면 앱 전용 저장소, 윈도우면 사용자 폴더 아래다. 경로를 직접 적으면 다른 플랫폼에서 바로 깨진다.

클릭과 자동 생산

void Update() 
{
    if (Input.GetMouseButtonDown(0)) 
    {
        DataController.Instance.gameData.Gold += DataController.Instance.gameData.GoldPerSec;
        TextGold.text = DataController.Instance.gameData.Gold.ToString();

        Ray ray = MainCamera.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;
        if (Physics.Raycast(ray, out hit, 100f)) 
        {
            Instantiate(EffectSpark, hit.point, EffectSpark.transform.rotation);
            MainCamera.gameObject.GetComponent<AudioSource>().PlayOneShot(SFXClick);
        }
    }
}

IEnumerator StartCollectGold() 
{
    while (true) 
    {
        yield return new WaitForSecondsRealtime(1f);
        DataController.Instance.gameData.Gold += DataController.Instance.gameData.GoldPerSec;
        TextGold.text = DataController.Instance.gameData.Gold.ToString();
    }
}

클릭 처리는 Update 에서, 자동 생산은 코루틴에서 한다. WaitForSecondsRealtime 을 쓴 건 Time.timeScale 을 0으로 만들어도 시간이 계속 흐르게 하기 위해서다.

지금 이 코드를 읽으면

2번째 — 컨셉을 얹다

먼저 밝혀둘 것두 번째 프로젝트는 골드메탈 유튜브 강좌를 보면서 따라 만든 것이다. 아래에 나오는 시스템 구성과 코드는 내가 처음부터 설계한 것이 아니라 그 강좌를 따라 구현하고 손본 결과다. 그림은 직접 그렸다. 그래서 이 편은 "내가 무엇을 판단했나"보다 "따라 만들면서 무엇이 눈에 들어왔나" 에 가깝다.

유튜버(스트리머)를 성장시키는 컨셉의 프로토타입이다. 클릭만 있던 자리에 일정 관리가 들어간다. 월·주 단위 캘린더가 있고, 캐릭터에게 컨디션과 멘탈 수치가 있고, 골드와 팔로워가 따로 오른다.

도트 그래픽 클리커 게임의 SCHEDULE 창. 1ST부터 4TH까지 네 슬롯에 행동 아이콘을 넣는 UI
주 단위 스케줄 창. 네 칸에 그림 · 방송 · 게임 · 수면을 배치한다.

행동은 세 가지다. 방송(Game) · 노래(Sing) · 그림(Draw). 무엇을 하느냐에 따라 소모되는 컨디션이 다르고, 캐릭터가 앉는 위치와 애니메이션도 달라진다.

switch (schedules[week])
{
    case "DRAW":
        Vector2 waterPos = GameObject.Find("의자").GetComponent<Transform>().position;
        player.transform.position = waterPos;
        playerAnimator.SetBool("물", true);
        playerAnimator.SetBool("밀기", false);
        playerAnimator.SetBool("쉬기", false);
        Status_Manager.Consume("CONDITION", 20f);
        break;
    case "SING":
        // ... (위치 0f 이동 및 밀기 애니메이션)
        Status_Manager.Consume("CONDITION", 15f);
        break;
    case "GAME":
        // ... (침대 위치 이동 및 쉬기 애니메이션)
        Status_Manager.Consume("CONDITION", 10f);
        break;
}
여기서 눈에 띄는 것행동마다 컨디션을 20 / 15 / 10 으로 다르게 소모한다. 그림이 제일 힘들고 게임이 제일 편하다는 설계다. 숫자가 코드에 직접 박혀 있어서 밸런스를 만지려면 스크립트를 열어야 한다. 따라 만들면서 "이 값들을 어디 두는 게 맞을까"를 처음 의식하게 된 지점이었다.

지치면 눕는다

컨디션이 0이 되면 isTired 디버프에 들어간다. 회복하려면 자거나 숨을 골라야 한다. 재미있는 건 눕는 동작을 위해 콜라이더 크기까지 바꾼다는 점이다.

public static void PlayerSleep()
{
    isSleep = true;

    GameObject player = GameObject.FindGameObjectWithTag("Player");
    BoxCollider2D collider = player.GetComponent<BoxCollider2D>();
    collider.size = new Vector2(0.7f, 0.3f); // 누운 상태 콜라이더 조정

    Vector2 bedPos = GameObject.Find("침대").GetComponent<Transform>().position;
    player.transform.position = bedPos;

    Animator playerAnimator = player.GetComponent<Animator>();
    playerAnimator.SetBool("isSleep", isSleep);
    playerAnimator.SetTrigger("doSleep");

    // 행동 진행도 UI 숨김 처리
    GameObject totalActiveBar = GameObject.Find("Active_Bar_Sprite");
    totalActiveBar.transform.localScale = Vector3.zero;
}

서 있을 때와 누워 있을 때 클릭 판정 영역이 같으면 안 되기 때문이다. 스프라이트만 바꾸고 콜라이더를 그대로 두면 누웠는데 머리 위 허공이 눌리는 상황이 생긴다.

여기서도 보이는 것

코드와 시스템 구성은 강좌를 따라갔지만, 화면에 보이는 그림은 일부 UI를 빼면 그림판으로 직접 그렸다. 전문 도트 툴을 쓴 게 아니라 마우스로 한 칸씩 찍은 것에 가깝다.

도트 그래픽 클리커 게임 플레이 화면. 상단에 연월 표시와 두 종류의 게이지, 화면 중앙에 클릭 수치 팝업
플레이 화면. 클릭할 때마다 진행도가 오르고 +0.2 팝업이 뜬다.

3번째 — 구조를 나누다

세 번째는 목표 자체가 달랐다. "기능이 돌아가게 짜는 수준을 넘어서 보자" 였다. 그래서 만든 것이 공용 연산 유틸리티, 게임 상태 머신, 매니저 분리다.

데미지 계산을 한곳으로

static public void CalcDamage(Unit attacker, Unit target)
{
    float attack = attacker.GetStatData(EStatsIndex.Attack);
    bool critical = false;
    float damageRatio = 1;

    // 크리티컬 확률 연산 (레벨 비례)
    float attackerCriticalValue = attacker.GetStatData(EStatsIndex.Critical);
    float attackerCriticalValueRatio = Util.CalcRatioValue(attackerCriticalValue, target.Level);
    if (attackerCriticalValueRatio <= 0) attackerCriticalValueRatio = 0;

    if (Util.IsSuccessChance(attackerCriticalValueRatio))
    {
        damageRatio *= 1.5f;
        critical = true;
    }

    // 방어력 연산 (레벨 비례 데미지 감소)
    float defence = target.GetStatData(EStatsIndex.Defense);
    float defenceLevelRatio = Util.CalcRatioValue(defence, attacker.Level);
    damageRatio *= (1 - defenceLevelRatio);

    int damage = (int)(attack * damageRatio);
    target.SetDamage(damage, critical);
}

Unit 두 개를 받아 계산한다. 누가 때리든 누가 맞든 같은 함수를 쓴다. 플레이어가 적을 때릴 때도, 적이 플레이어를 때릴 때도 마찬가지다. 1번째에서 클릭 처리 안에 재화 계산이 섞여 있던 것과 대비된다.

UI 위를 눌렀는지 먼저 본다

public void ProcessTouch()
{
    // UI 터치 방지
    if (EventSystem.current.IsPointerOverGameObject()) return;

    if (GameStateMachineManager.Instance.IsCurrState(EGameState.Battle) == false
        && GameStateMachineManager.Instance.IsCurrState(EGameState.BossBattle) == false)
        return;

    if (Input.GetMouseButtonDown(0) == false) return;

    Vector3 touchPos = Input.mousePosition;
    Vector3 worldPos = Camera.main.ScreenToWorldPoint(new Vector3(touchPos.x, touchPos.y, 50));
    
    // 이펙트 생성 및 효과음 재생
    GameObject effectObject = ResourceManager.Instance.ClonePrefab("Effect/HitEffect");
    effectObject.transform.position = worldPos;
    SoundManager.Instance.PlayActionSound("Peok");

    // 공격 판정
    Util.CalcDamage(UnitManager.Instance.Player, UnitManager.Instance.Enemy);
}
세 번 만들어서 얻은 두 줄맨 위 EventSystem.current.IsPointerOverGameObject() 와 그다음 상태 검사. 1번째에서 UI를 눌러도 게임 화면이 같이 눌리던 문제가 여기서 막힌다. 그리고 지금이 전투 상태인지부터 확인하므로, 로딩 중이나 결과 화면에서 두드려도 아무 일이 없다. 같은 장르를 세 번 만들면서 실제로 달라진 건 이런 것들이다.

이펙트도 Instantiate 를 직접 부르지 않고 ResourceManager 를 거치고, 효과음도 SoundManager 를 거친다. 1번째에서 MainCameraAudioSource 를 매번 찾아 쓰던 것과 다르다.

저장을 다시

static public void SaveBinaryFormat(string key, object data)
{
    string jsonData = Util.JsonSerializeObject(data);

    BinaryFormatter b = new BinaryFormatter();
    MemoryStream m = new MemoryStream();
    b.Serialize(m, jsonData);

    PlayerPrefs.SetString(key, Convert.ToBase64String(m.GetBuffer()));
}

1번째는 JSON을 파일로 그냥 썼다. 세 번째는 JSON으로 직렬화한 뒤 Binary로 인코딩해 PlayerPrefs 에 넣는다. 클리커 게임은 데이터가 곧 진행도라 저장을 신경 쓴 것이다.

세 번 만들면서 달라진 것

1번째2번째3번째
클릭 처리Update 에서 직접2D Raycast + 이펙트 코루틴상태 검사 후 ProcessTouch
UI 위 클릭구분 없음isActiveUI 플래그로 일부 차단EventSystem 으로 차단
데이터DataController 싱글톤 + JSON 파일Status_Manager 정적 메서드Json → Binary → PlayerPrefs
연산각 함수 안에 흩어짐매니저별로 나뉨Util 로 공용화
상태없음isTired / isSleep 불리언FSM (Ready / Battle / BossBattle)
리소스그림판으로 직접 그림일부 UI 외에는 그림판으로 직접 그림인터넷에서 구한 리소스

정리

세 프로젝트의 회고를 각각 한 줄로 줄이면 이렇게 된다.

돌아보면 같은 게임을 세 번 만든 게 아니다. 같은 코어 루프 위에 매번 다른 층을 올려 본 것에 가깝다. 첫 번째에서 넘어간 것(UI 클릭 관통)이 세 번째에서 두 줄로 막히는 걸 보면, 반복이 헛돈 건 아니었다고 생각한다.

두 번째를 강좌로 따라간 것도 결과적으로는 도움이 됐다. 혼자 짤 때는 나오지 않았을 구성(스케줄·스탯·디버프가 서로 물리는 방식)을 한 번 통과해 본 다음이라, 세 번째에서 무엇을 나눠야 하는지가 조금 더 분명하게 보였다.