한 판이 30초 남짓인 게임은 끝날 때마다 "한 판만 더"와 "그만"이 갈린다. 점수 경쟁만으로는 오래 붙잡기 어렵다. 그래서 하루 단위로 목표를 하나 주고, 판마다 모은 별을 쓸 곳을 만들었다. 데일리 미션과 스킨 상점이다.
둘 다 서버 없이 폰 안에서만 돈다. 이 글에서는 미션을 뽑는 방법과 진행도 규칙, 스킨 가격, 그리고 이걸 전부 담는 저장 구조를 다룬다.
오늘의 미션은 날짜가 정한다
타이틀 화면 아래에 「오늘의 미션」 카드가 있다. 미션은 네 종류다.
| 종류 | 목표 | 진행도 |
|---|---|---|
| 별 모으기 | 10 / 15 / 20개 | 여러 판 누적 |
| 점수 달성 | 한 판에 800~1,600점 | 한 판 최고 |
| 아슬아슬 회피 | 5 / 10 / 15회 | 여러 판 누적 |
| 생존 시간 | 한 판에 40~70초 | 한 판 최고 |

미션을 고르는 데 서버를 쓰지 않는다. 오늘 날짜를 숫자로 바꿔 난수 시드로 쓴다.
static void RefreshMission()
{
string today = DateTime.Now.ToString("yyyyMMdd");
var m = D.mission;
if (m != null && m.date == today) return;
var rng = new System.Random(int.Parse(today));
m = new DailyMission { date = today, type = rng.Next(4) };
switch (m.type)
{
case 0: m.target = 10 + rng.Next(3) * 5; break; // 10/15/20 별
case 1: m.target = 800 + rng.Next(5) * 200; break; // 800~1600 점
case 2: m.target = 5 + rng.Next(3) * 5; break; // 5/10/15 회
default: m.target = 40 + rng.Next(4) * 10; break; // 40~70 초
}
D.mission = m;
}
20261013처럼 날짜를 정수로 바꾸면 int 범위 안에 들어간다. 같은 시드로 만든 System.Random은 같은 순서로 수를 내놓으니, 같은 날에는 앱을 몇 번 다시 켜도 같은 미션이 나온다. 앱을 지웠다 다시 깔아도 마찬가지다. 날짜가 바뀌면 저장된 date와 달라지므로 새로 뽑는다.
게임 플레이에 쓰는 UnityEngine.Random과 섞지 않으려고 미션에는 따로 System.Random 객체를 만들었다. 공용 난수에 시드를 걸면 블록 배치까지 날마다 똑같아진다.
시간은 폰의 현지 시각 기준이다. 자정이 지나면 바로 새 미션이 나온다. 폰 날짜를 바꾸면 미션도 바뀐다. 보상이 별 30개뿐이라 이를 막는 장치는 넣지 않았다.
진행도는 미션 종류마다 다르게 센다
별 모으기와 니어미스는 여러 판에 걸쳐 더한다. 점수와 생존 시간은 한 판 기록 중 가장 좋은 값만 남긴다. "한 판에 1,000점"을 여러 판 합쳐서 채우면 미션이 너무 쉬워진다.
switch (m.type)
{
case 0: m.progress += r.stars; break;
case 1: m.progress = Math.Max(m.progress, r.score); break;
case 2: m.progress += r.near; break;
case 3: m.progress = Math.Max(m.progress, (int)r.time); break;
}
m.progress = Math.Min(m.progress, m.target);
결과 화면에서는 「오늘의 미션 별 10개 모으기 8/10」처럼 진행도를 바로 보여 준다. 판이 끝날 때마다 목표까지 얼마 남았는지 보이면 한 판을 더 하게 된다.

목표를 채우면 보상은 결과 화면이 아니라 타이틀에서 받는다. 미션 카드에 「보상 ★30」 버튼이 뜨고, 눌러야 별이 들어온다. 타이틀로 돌아올 이유를 하나 만든 것이다. 받고 나면 그날은 진행도를 더 세지 않는다.

최고 기록 아래 줄에는 누적 플레이 수, 최장 생존 시간, 지금까지 모은 별 수를 작게 보여 준다. 첫 판 전에는 「첫 판을 시작해 보세요」가 그 자리에 뜬다.
스킨 8종, 별로 산다
판마다 먹은 별은 점수와 별개로 쌓이는 재화다. 이 별로 바 스킨을 산다.

| 스킨 | 가격 | 스타일 |
|---|---|---|
| 시안 | 무료 | 단색 (기본) |
| 라임 | ★50 | 단색 |
| 마젠타 | ★80 | 단색 |
| 오렌지 | ★120 | 단색 |
| 화이트 | ★160 | 펄스 — 흰색으로 밝아졌다 돌아온다 |
| 골드 | ★200 | 펄스 |
| 고스트 | ★300 | 반투명하게 깜빡임, 너비 130px |
| 레인보우 | ★400 | 색상환을 천천히 돈다 |
스킨은 색만 바꾸는 것이 아니라 ColorAt(t)로 시간에 따라 색을 계산한다. 게임 화면은 매 프레임 이 값을 바 몸체와 글로에 칠한다.
public Color ColorAt(float t)
{
switch (Style)
{
case Mode.Rainbow: return Color.HSVToRGB((t * 0.35f) % 1f, 0.75f, 1f);
case Mode.Pulse: return Color.Lerp(Color, Color.white, 0.5f + 0.5f * Mathf.Sin(t * 6f));
case Mode.Ghost: return UIFactory.Alpha(Color, 0.55f + 0.2f * Mathf.Sin(t * 3f));
default: return Color;
}
}
고스트만 너비가 다르다. 기본 150px보다 20px 좁아서 블록 사이를 빠져나가기는 쉽지만 별을 받기는 어렵고, 몸체가 반투명하게 깜빡여 바 위치를 읽기가 까다롭다. 가격이 높은 스킨에 성능 차이를 크게 두면 별을 많이 모은 사람만 유리해지니, 차이는 이 정도로만 두었다.
전부 사려면 별 1,310개가 필요하다. 한 판에 별을 몇 개씩 먹고, 미션으로 하루 30개가 더 들어온다. 라임(★50)은 미션 보상 두 번이면 사고, 뒤로 갈수록 가격 간격을 벌려 레인보우는 꽤 오래 모아야 한다.
이어하기를 해도 별이 두 번 들어오지 않게
한 판의 결과는 RunResult 하나로 모아 GameData.Commit()에 한 번 넘긴다. 최고 기록, 보유 별, 누적 통계, 미션 진행도를 이 한 곳에서 반영하고 저장한다.
문제는 이어하기다. 결과 화면에서 광고를 보고 이어하면 같은 판이 계속된다. 죽을 때 이미 별 4개를 반영했는데, 이어서 별 2개를 더 먹고 다시 죽으면 6개를 또 넣으면 안 된다. 그래서 이미 반영한 양을 기억해 두고 차이만 넘긴다.
RunResult MakeResult()
{
var r = new RunResult
{
score = (int)_score,
stars = _stars - _starsCommitted,
near = _near - _nearCommitted,
time = _t,
phase = _phase,
revived = _revived,
};
_starsCommitted = _stars;
_nearCommitted = _near;
return r;
}
점수와 시간은 판 전체 값을 그대로 넘긴다. 최고 기록과 "한 판 최고" 미션은 큰 값만 남기니 두 번 넘어가도 문제없다. 플레이 횟수는 revived가 참이면 세지 않는다.
일시정지에서 그만하기를 눌러도 그때까지의 결과를 똑같이 반영한다. 먹은 별을 잃지 않는다.
저장은 JSON 문자열 하나
저장 데이터는 SaveData 클래스 하나를 JsonUtility로 직렬화해 PlayerPrefs의 키 하나에 넣는다. 최고 기록, 보유 별, 선택한 스킨, 보유 스킨 목록, 누적 통계, 오늘의 미션이 전부 여기 들어간다. Android에서는 SharedPreferences에, 웹 버전에서는 브라우저의 IndexedDB에 저장된다.
[Serializable]
public class SaveData
{
public int best; // 최고 점수
public int stars; // 보유 별 (스킨 구매 재화)
public int skin; // 선택한 스킨
public bool[] owned = new bool[Skins.Count];
public int runs; // 플레이 횟수
public int totalStars; // 누적 획득 별
public int totalNear; // 누적 니어미스
public float bestTime; // 최장 생존(초)
public DailyMission mission = new DailyMission();
}
읽을 때는 나중에 구조가 바뀌어도 깨지지 않게 몇 가지를 보정한다.
if (D.owned == null || D.owned.Length != Skins.Count)
{
var o = new bool[Skins.Count];
if (D.owned != null) Array.Copy(D.owned, o, Math.Min(D.owned.Length, o.Length));
D.owned = o;
}
D.owned[0] = true;
if (D.skin < 0 || D.skin >= Skins.Count || !D.owned[D.skin]) D.skin = 0;
- 스킨이 늘어나면 보유 목록 배열 길이가 맞지 않는다. 새 길이로 만들고 기존 값만 복사한다. 이미 산 스킨은 그대로 남는다.
- 기본 스킨은 항상 보유로 둔다.
- 선택한 스킨 번호가 범위를 벗어나거나 보유하지 않은 스킨이면 기본으로 돌린다.
- JSON을 읽다 예외가 나면 경고를 남기고 새 데이터로 시작한다. 저장이 깨져서 앱이 아예 안 켜지는 것보다 낫다.
JsonUtility는 JSON에 없는 필드를 클래스 기본값으로 둔다. 필드를 추가하는 정도는 따로 처리하지 않아도 된다.
정리
- 데일리 미션은
yyyyMMdd를 시드로 한System.Random으로 뽑는다. 같은 날이면 같은 미션이고, 게임용 난수와는 분리했다. - 누적 미션(별·니어미스)은 더하고, 한 판 미션(점수·생존)은 최고값만 남긴다. 보상은 타이틀에서 받는다.
- 스킨 8종은 별 50~400개다. 스타일은
ColorAt(t)로 프레임마다 색을 계산하고, 성능 차이는 고스트의 너비 하나만 두었다. - 결과는
Commit()한 곳에서 반영하고, 이어하기는 이미 반영한 별·니어미스를 빼고 넘긴다. - 저장은
PlayerPrefs키 하나에 JSON 하나. 읽을 때 배열 길이·선택 번호를 보정해 스킨이 늘어도 깨지지 않는다.
다음 글 5편 (10월 14일 공개)에서는 이 세로 게임을 브라우저에 올리며 손본 WebGL 템플릿과 회색 로딩 화면을 다룬다.