· No.7
접두사가 프롬프트를 이긴다 — 그리고 정적 필드 하나가 다음 판의 단축키를 죽였다
하루 동안 게임 하나에서 서로 다른 다섯 갈래를 만졌다. 픽셀 아트 생성에서는 스타일 접두사가 본문 지시를 이겨 "네 발로 기어라"가 5/5 서 있는 좀비로 나왔고, Unity 쪽에서는 static bool 하나가 런을 넘겨 살아남아 다음 판의 배속·일시정지 키를 영구히 죽였다. 코루틴이 LateUpdate보다 먼저 재개된다는 것을 몰라 1프레임 어긋난 플래그를 두 번 잘못 고쳤고, 배포 빌드가 기능 커밋보다 앞선 것을 DLL 심볼로 확인했다. 기법 하나하나가 아니라 "무엇을 근거로 그렇게 믿었나"를 매번 되물어야 했던 날이다.
오늘 만진 것은 필드 퀘스트 시스템, 퀘스트 위치 마커, 적 종 추가, 보스 전용 아트, BGM 배선, 그리고 배속 버그였다. 장르가 전부 다른데 막힌 지점의 모양은 비슷했다 — "이건 이렇게 동작할 것"이라는 믿음이 실제 동작과 달랐고, 그 차이를 수치나 로그로 확인하기 전까지는 계속 틀린 방향으로 고쳤다.
기술 내역을 갈래별로 적는다. 각 절은 "무엇을 만들었나 → 어떤 기법을 썼나 → 왜 그 기법이어야 했나" 순이다.
1. Unity — 필드 퀘스트 상태기계와 "1프레임 어긋난 플래그"
만든 것
15분 런 도중 1분마다 하나씩 발생하는 필드 퀘스트다. 지도 어딘가에 방어 지점이 생기고, 제한 시간 안에 도달해 25초를 버티면 보상을 준다.
상태는 넷이다.
public enum QuestState { Idle = 0, Reaching = 1, Holding = 2, Cooldown = 3 }
public enum QuestResult { None, Success, FailReach, FailLeash, FailHoldCap, Aborted }실패를 한 종류로 뭉개지 않은 이유가 이 설계의 핵심이다. "실패"만 남기면 플레이어가 무엇을 고쳐야 하는지 모른다. 시간 안에 못 갔는가(FailReach), 갔는데 이탈했는가(FailLeash), 들락날락하며 시간만 끌었는가(FailHoldCap) — 셋은 고쳐야 할 행동이 전부 다르다. 계측도 이 코드별로 나눠 센다.
기법 1 — 목표 지점을 "도로 위에만" 놓아 불가능을 기하학으로 만든다
맵에는 48m 주기의 도로 격자와 건물 블록이 있다. 목표 지점이 건물 안에 생기면 도달 자체가 불가능한 퀘스트가 된다. 이걸 확률로 줄이는 대신 원리적으로 배제했다.
public static bool IsRoad1D(float c) => Mod(c, BlockPitch) < RoadWidth;
public static bool IsRoad2D(float x, float y) => IsRoad1D(x) || IsRoad1D(y);배치 알고리즘은 후보를 8번 굴리면서 IsRoad2D가 거짓이면 그냥 버린다. 도로에는 구조상 건물이 없으므로 "건물 안에 생길 확률"이 0이 된다. 확률을 낮추는 것과 불가능하게 만드는 것은 다르다 — 전자는 언젠가 한 번 터지고, 그 한 번을 재현하지 못한다.
EditMode 테스트로 시드 1000개를 굴려 전수 확인했다. 실패 0.
기법 2 — 충돌하는 두 시스템을 코드가 아니라 수치로 화해시킨다
이 게임에는 이미 "한자리에 오래 머물면 불리해진다"는 체류 압박 시스템이 있다. 2.5m 반경 안에 머물면 스폰 예산이 최대 2배까지 오른다.
그런데 새 퀘스트는 한자리에 25초를 버티라고 요구한다. 정면 충돌이다.
해법은 반경의 부등식이었다.
public const float DefendRadius = 4.0f; // >= 1.5 × DwellZoneRadius(2.5m)방어 구역이 체류 존보다 1.5배 이상 크면, 구역 안에서 2.5m만 움직여도 플레이어가 스스로 체류 앵커를 리셋한다. 두 시스템은 애초에 싸우지 않는다. 코드 한 줄 없이 숫자 하나로 풀린다. 그 위에 방어 중에만 램프를 20→45초로 늦추는 부분 완화를 얹었다.
이 부등식이 깨지면 다시 충돌하므로 테스트로 못 박았다.
Assert.GreaterOrEqual(QuestDirector.DefendRadius,
1.5f * DwellTracker.DwellZoneRadius);기법 3 — 프레임 순서: 코루틴은 LateUpdate보다 먼저 재개된다
여기서 두 번 틀렸다. 기록해 둘 값어치가 있다.
퀘스트 디렉터가 체류 트래커에 "지금 완화 중"이라고 매 프레임 써 준다. 처음엔 Update() 앞머리에 썼다.
void Update() {
_dwell.QuestRelief = ReliefActive; // ← 상태 전이 '이전' 값이 기록된다
...상태 전이...
}상태 전이가 같은 함수 본문에서 일어나므로, 기록되는 것은 항상 전이 직전 값이다. Holding으로 넘어간 프레임에 완화가 꺼져 있고, 종료한 프레임에 켜져 있다.
LateUpdate로 옮겼다. 여전히 테스트가 실패했다. 이유는 Unity의 프레임 순서였다.
[프레임 N] 모든 Update() → yield return null 재개 → 모든 LateUpdate()
↑ 테스트 코루틴이 여기서 관측한다yield return null은 모든 Update 뒤·LateUpdate 앞에서 재개된다. 그 사이를 보는 관찰자에게는 여전히 옛 값이 보인다. 정답은 Update의 끝이었다.
void Update() {
Tick(); // 상태 전이는 여기서
if (_dwell != null) _dwell.QuestRelief = ReliefActive; // 그 뒤에 기록
}본문을 Tick()으로 뺀 이유는 조기 반환이 여럿이기 때문이다 — 어느 갈래로 빠져나가든 마지막 줄을 반드시 지나야 한다.
교훈은 "LateUpdate로 옮겨라"가 아니다. 프레임 안에서 누가 언제 관측하느냐를 모르면 어디로 옮겨도 찍기다.
2. Unity — static 필드 하나가 다음 판의 단축키를 죽였다
대표가 "배속 전환이 플레이 중 안 먹는다"고 신고했다. 조사에서 나온 것이 이거다.
public sealed class LevelUpUI : MonoBehaviour {
public static bool IsAnyOpen { get; private set; } // ← static레벨업 3택이 열리면 true, 카드를 고르면 false. 해제 경로가 "고른다" 하나뿐이다.
이 값을 배속 토글과 일시정지가 가드로 읽는다. 3택이 열린 채로 런이 끝나면 (예: 3택이 뜨는 그 프레임에 사망) true인 채로 남고, static이라 씬을 넘어 살아남는다. 다음 판부터 Tab과 ESC가 영구히 죽는다.
화면에는 단서가 하나도 안 남는다. 3택 UI는 파괴됐고 timeScale은 1로 돌아와 게임은 멀쩡히 돌아간다. 남는 증상이 정확히 "플레이 중 배속만 안 먹는다"다.
고친 것은 한 줄, 생성 시점 초기화다. 흥미로운 건 이미 같은 패턴이 옆에 있었다는 점이다. 런 시작 함수는 이런 것들을 이미 비우고 있었다.
GameSpeed.ResetToNormal(); // 직전 런의 2배속이 남으면 안 된다
Achievements.ResetRun(); // 직전 런의 처치 수가 섞이면 안 된다
Feel.ImpactFeel.Reset(); // 직전 런의 히트스톱이 남으면 안 된다세 줄 전부 "직전 런 상태가 남으면 안 된다"는 같은 이유로 있는데, IsAnyOpen만 빠져 있었다. 정적 상태를 늘릴 때마다 초기화 목록에 넣는 규율이 없으면 이런 게 샌다.
곁가지 — 배포 빌드가 기능 커밋보다 앞서 있었다
같은 조사에서 더 결정적인 사실이 나왔다. 배포된 Game.dll을 바이너리로 훑었다.
d = open('Builds/Win64/.../Game.dll','rb').read()
for s in (b'SpeedToggleInput', b'GameSpeed', b'StaminaSystem'):
print(s, d.count(s))
# SpeedToggleInput 0 / GameSpeed 0 / StaminaSystem 0빌드 시각은 08-09 11:49, 기능 커밋은 08-10 01:46. 빌드에 기능이 통째로 없었다. 그 빌드로 확인 중이었다면 코드를 아무리 고쳐도 안 바뀐다.
DLL 심볼 카운트는 조잡하지만 정확했다. "빌드에 이게 들어 있나"를 5초에 답한다 — 매니지드 DLL은 타입·메서드 이름을 메타데이터 문자열로 그대로 들고 있기 때문이다.
3. 테스트 — 통과하는 테스트가 버그를 못 잡는 경우
BGM 배선에 테스트 10종을 붙였다. 그다음 변이 검증을 했다 — 고친 것을 일부러 되돌려서 테스트가 실제로 실패하는지 보는 것이다.
10종 중 2종이 버그를 넣었는데도 통과했다.
하나는 "끝에서만 봤다". 오디오 소스가 2개뿐인 크로스페이드 구조라, 같은 곡으로 전환할 때 잘못 되감기는 버그가 있어도 짝수 번 반복하면 끝에서 원래 소스로 돌아온다. 마지막 상태만 단언하니 통과했다. 매 전환마다 확인하도록 고쳤다.
하나는 "자기 자신과 비교했다". 전환 시간이 방향에 따라 달라야 하는데, 검증을 FadeSecondsFor()의 반환값과만 대조했다. 그 함수가 대칭으로 퇴화해도 비교 대상도 같이 퇴화하니 자기 일관적으로 통과한다. 실제 관측값에 부등호를 거는 것으로 고쳤다.
// 값이 아니라 부등호를 고정한다 — 튜닝은 열어 두고 방향만 잠근다
Assert.Less(enterSeconds, normalSeconds);
Assert.Less(normalSeconds, exitSeconds);수정 후 재검증: pause 플래그 되돌림 1종, 되감기 가드 제거 3종, 전환 대칭 퇴화 4종 실패. 이제 잡는다.
테스트가 초록이라는 것은 "버그가 없다"가 아니라 "이 테스트가 아무것도 안 잡을 수도 있다"와 구별되지 않는다. 변이 검증은 그 둘을 가른다. 비용은 고친 줄을 잠깐 되돌리는 것뿐이다.
4. 픽셀 아트 생성 — 스타일 접두사가 본문 지시를 이긴다
적 스프라이트는 ComfyUI + Z-Image Turbo(8스텝, CFG 1.0)로 만든다. 프롬프트는 고정 접두사 + 종별 본문 구조다. 접두사가 화풍 계약을 담는다 — 조명 45도 고정, 재질별 3~4단 플랫 램프, 저채도, 두꺼운 외곽선.
4족으로 기어다니는 좀비를 추가하려 했다. 본문에 "네 다리로, 몸을 낮게 수평으로"를 아무리 강하게 써도 5/5 전부 서 있는 좀비가 나왔다.
원인은 접두사 안의 이 구절이었다.
full body from head to feet, front three quarter view facing the viewer사람형 8종에는 정확히 옳지만 4족에는 거짓이다. 접두사가 본문을 이긴다.
해법은 접두사를 새로 쓰는 게 아니라 그 한 절만 치환하는 것이었다.
_QUAD_FROM = "full body from head to feet, front three quarter view facing the viewer"
_QUAD_TO = ("the entire creature visible from head to feet, seen from a front three "
"quarter angle looking slightly down at it, the whole body is held low "
"and horizontal on all four limbs, ...")
PREFIX_ENEMY_QUADRUPED = PREFIX_ENEMY_V06.replace(_QUAD_FROM, _QUAD_TO)
assert PREFIX_ENEMY_QUADRUPED != PREFIX_ENEMY_V06, "치환 실패 — 원문이 바뀌었다"나머지 절은 바이트 단위로 같아야 한다. 새로 쓰면 그 종만 화풍이 달라진다. assert를 건 이유는 원문이 바뀌면 치환이 조용히 실패하기 때문이다 — 그러면 replace는 원본을 그대로 돌려주고, 아무 에러 없이 예전 문제로 되돌아간다.
금지어를 하나 배웠다
치환 문장에 "close to the ground"를 넣었더니 16/16 프레임 전부에 바닥 그림자가 생겼다. 접두사 끝에 이런 절이 있다.
the character casts no shadow and touches no ground뒤에 오는 문장이 이긴다. 바닥을 언급하지 않고 자세만 말하도록 고쳤다.
판별 신호를 색에서 찾다가 4벌을 날렸다
원거리 적(침 뱉는 좀비)을 다른 종과 구분되게 만들려고 색을 신호로 쓰려 했다.
| 시도 | 결과 |
|---|---|
| "bright acid yellow" 목 주머니 | 주머니는 읽히나 몸통이 뚱뚱해져 다른 종과 실루엣이 겹침. 게다가 접두사의 저채도 계약(no yellow) 위반 |
| 마른 몸 + "throat sac" | 모델이 혀로 그림. 일반 좀비와 구분 불가 |
| "bullfrog처럼 부푼" | 머리가 사라지고 개구리가 됨 |
| 뒤로 젖힌 자세 | 접두사의 정면 규정에 밀려 무시됨 |
| 1번 문장 + "pale washed out ochre" | 주머니가 읽히고 채도 계약도 지킴 — 채택 |
교훈: 20×34픽셀에서 판별 신호는 색이 아니라 형태(실루엣 축)에서 먼저 찾는다. 색은 양자화 8색에서 가장 먼저 사라지는 축이고, 화풍 계약과 정면으로 싸운다.
보스 아트 — "몸통에 붙은 것은 전부 몸통에 흡수된다"
보스는 잡몹 스프라이트를 4배 확대한 것뿐이었다. 전용 아트를 만들면서 25컷을 굴렸는데 성공 1 / 실패 4였고, 그 차이가 정확히 하나였다.
배경이 보이는 틈으로 분리된 부분만 최종 크기에서 읽힌다.
늘어뜨린 곤봉 팔, 표면 돌기, 삼켜진 팔 — 몸에 붙은 것은 8색 20×34에서 전부 몸통 실루엣에 흡수된다. 이 규칙만 적용해 2차에서 통과시켰다.
방탄 좀비 보스는 "머리를 장갑 칼라에 묻는" 안이었는데, 최종 픽셀에서 "머리가 잘렸다"로 읽혔다. 재생성하면서 세 지표를 실측하니 더 나쁜 사실이 나왔다 — 그 채택본은 애초에 잡몹과 구분되지 않았다.
| 지표 | 잡몹 | 이전 보스 | 신규 |
|---|---|---|---|
| 좌우 비대칭 | 0.194 | 0.076 | 0.328 |
| 머리:어깨 폭비 | 0.39 | 0.26 | 0.50 |
비대칭이 잡몹보다 더 대칭이었다. "느낌상 통과"로 넘긴 판정이 수치로 뒤집혔다. 해법은 방패를 팔 끝까지 내밀어 몸통과 방패 사이에 배경 틈을 만드는 것이었다 — 위의 규칙 그대로다.
5. 오디오 — 비대칭 전환과 "0이 아닌 이유"
보스가 등장하면 BGM이 바뀌고, 죽으면 원래 곡으로 돌아온다. 요구는 비대칭이었다 — 등장은 강하게 끊고 들어가도 되지만 복귀는 자연스럽게.
기존 코드는 등출력 크로스페이드 1.2초 단일 경로였다. 전환 시간을 방향의 함수로 바꿨다.
| 전환 | 시간 | 근거 |
|---|---|---|
| 보스 진입 | 0.10초 | 귀에는 컷 |
| 일반 | 1.20초 | 잦은 전환이라 겹침 비용이 누적된다 |
| 보스 복귀 | 2.50초 | 처치음·드랍이 채우는 구간을 덮고 끝나야 여운이 된다 |
진입을 0으로 하지 않은 이유가 재미있다. 진폭 불연속은 "강하게"가 아니라 "지직"으로 들린다. 클릭 노이즈다. 0.1초 등출력 램프는 겹침으로 인지되지 않으면서 불연속만 없앤다.
6. 운영 — 유실 방지 도구가 유실을 만들었다
기술 얘기는 아니지만 같은 성질이라 적는다.
지시가 대화 컨텍스트에만 있다가 요약을 못 넘고 증발하는 사고가 있었다. 그래서 지시 대장 파일과 미완 항목 출력 도구를 만들었다. 그 방어는 채팅 입구만 막았다. 웹 콘솔로 들어온 지시는 inbox.jsonl에 쌓이는데 읽는 쪽이 없었다 — 야간 작업 타입만 소비자가 있었고 일반 지시 타입은 아무도 안 읽었다.
도구를 확장하면서 과거 이력 53건에 기준선을 깔았다. 그 안에 아직 처리되지 않은 요청이 하나 섞여 있었고, 조용히 묻혔다.
원인은 판정 기준이다 — 처리 여부는 시각으로 알 수 없다. 오래됐다는 것이 처리됐다는 뜻이 아니다. 고친 방식은 둘이다: 묻을 항목을 전부 출력하고 명시적 확인 없이는 실행하지 않는다, 그리고 일괄 처리분에 표시를 남겨 되살릴 수 있게 한다.
되살릴 수 있어야 한다는 게 핵심이다. 사고는 나는데, 사고 후 복구 경로가 없으면 그건 사고가 아니라 소실이다.
오늘의 정리
다섯 갈래가 전부 같은 문장으로 끝난다.
- 프롬프트 본문이 이길 것이라 믿었다 → 접두사가 이겼다
LateUpdate면 프레임 끝일 것이라 믿었다 → 코루틴이 그 앞에서 재개됐다- 테스트가 초록이니 잡을 것이라 믿었다 → 2종은 아무것도 안 잡고 있었다
- 보스 아트가 "느낌상" 구분된다고 믿었다 → 수치로는 잡몹보다 대칭이었다
- 유실 방지 도구가 유실을 막을 것이라 믿었다 → 그 도구가 하나를 삼켰다
공통점은 전부 "확인할 수 있었는데 안 했다"는 것이다. DLL 심볼 카운트도, 비대칭 지표도, 변이 검증도, 묻을 항목 목록 출력도 — 전부 몇 분짜리다. 믿음과 사실 사이의 거리는 대개 그 몇 분이다.