모래게임 Sandrop TIL 18
양자화와 색
두 임계값이 어긋나면 아무도 통과 못 하는 사각지대가 생긴다
도안을 색 여섯 개로 줄인 뒤 두 가지를 본다.
- 비슷한 색끼리 합치기 — 거리 38 미만이면 하나로
- 통과 판정 — 색끼리 45 이상 떨어져야 함
거리가 38~45인 색 짝은 합쳐지지도 통과하지도 못한다. 합치는 기준이 통과 기준보다 낮으면 그 사이 구간이 통째로 사각지대가 된다. 노토 이모지 1440장 중 268장이 여기 갇혀 버려지고 있었다.
FINAL_MERGE_DIST2 = 38 ** 2 # 이보다 가까우면 합친다
MIN_PALETTE_DIST = 45 # 이보다 가까우면 탈락
# → 거리 38~45 는 합쳐지지도, 통과하지도 못한다
교훈은 단순하다. 거르는 기준과 고치는 기준이 따로 있으면, 고치는 쪽이 거르는 쪽보다 강해야 한다. 아니면 그 사이가 사각지대가 된다.
재시도가 한 방향으로만 가면 반대쪽 실패는 못 고친다
색이 모자라서 떨어지는 도안을 구제하려고, 재시도할수록 병합을 점점 푸는 사다리를 두고 있었다.
MERGE_LADDER = ((22, 28, 0.004), (16, 20, 0.002), (10, 12, 0.001))
# 점점 덜 합치는 방향 — "색이 모자람"은 구제되지만
# "색이 너무 비슷함"은 오히려 나빠진다
실패에는 두 방향이 있는데 구제 수단은 한 방향뿐이었다. 통과 기준보다 세게 합치는 단을 끝에 하나 붙이자 268종이 살아났다.
MERGE_LADDER = (..., (34, 48, 0.006)) # 게이트(45)보다 세게 합쳐 사각지대를 닫는다
앞 단에서 이미 통과하던 도안은 그대로라 순수하게 늘어나기만 한다. 합친 뒤에는 색이 48 이상 벌어지니 구분 기준은 오히려 더 엄격해진다.
median-cut은 면적으로 색을 나눈다 — 작은 색은 색 수를 늘려도 안 잡힌다
김치 그림에서 파(초록)가 사라졌다. 초록 픽셀은 분명히 1.4% 남아 있는데 팔레트에 없었다.
색 수를 6에서 16까지 올려봐도 초록 자리가 안 생겼다. median-cut은 색 공간을 픽셀 수 기준으로 쪼개기 때문에, 넓은 흰색·빨강이 칸을 다 가져가고 좁은 초록은 큰 덩어리에 흡수된다.
q = rgb.quantize(colors=16, method=Image.Quantize.MEDIANCUT)
# 16색을 줘도 클러스터는 흰색 계열이 6개, 초록은 0개
MAXCOVERAGE·FASTOCTREE로 바꿔도 결과가 같았다. 색 수를 늘리는 건 답이 아니었다.
진짜 원인은 불투명 배경이었다
그 그림은 배경이 흰색 불투명이라 캔버스의 55%가 흰색이었다. 그 흰색이 전경으로 취급돼 색 배정을 다 먹고 있었다.
배경을 투명으로 바꾸자 팔레트가 4색에서 7색으로 늘고 파가 살아났다. 알파로 전경을 가리는 파이프라인에서는 투명 배경이 그림 품질을 좌우한다 — 색 알고리즘을 손보기 전에 입력부터 봐야 했다.
| 불투명 배경 | 투명 배경 | |
|---|---|---|
| 색 수 | 4 | 7 |
| 파 | 없음 | 살아남음 |
| 모양 | 꽉 찬 사각형 | 그림 실루엣 |
해상도
해상도를 올리면 통과율이 그냥 오르지는 않는다
도안 원본을 32에서 64로 올리면 디테일이 살아나니 더 많이 통과할 줄 알았다. 실제로는 167종이 32에서는 통과하는데 64에서 떨어졌다.
이유는 반대 방향이었다. 32로 줄일 때는 미묘한 음영이 뭉개져 하나의 뚜렷한 색이 되는데, 64에서는 그 음영이 살아남아 비슷한 색 두 개로 갈라져 “너무 비슷함”에 걸린다.
탈락 사유 분포 (64에서 새로 떨어진 167종)
색거리 부족 147 (88%)
색 수 부족 19
채움 비율 1
축소가 노이즈 제거 역할을 하고 있었던 셈이다. 해상도를 올리는 건 정보를 더 주는 동시에 걸러지던 잡음도 같이 살리는 일이다.
비율로 정한 두께는 해상도가 바뀌면 두께가 바뀐다
태극 도안의 검은 테두리가 갑자기 두꺼워 보인다는 지적을 받았다. 코드는 안 건드렸는데도.
테두리를 반지름 비율(0.86 − 0.76 = 0.10)로 정해 뒀는데, 32에서는 1.6칸이던 것이 64에서는 3.2칸이 됐다. 비율은 그대로여도 절대 칸 수가 두 배가 된 것이다.
R, RING = 0.86, 0.76 # 32에서 1.6칸 → 64에서 3.2칸
RING = 0.80 # 예전 두께로 되돌림 (1.9칸)
좌표 함수로 그린 도형은 해상도를 자동으로 따라가지만, 선의 두께처럼 “몇 칸”이 중요한 값은 따라가지 않는다.
손으로 찍은 데이터는 해상도를 못 따라간다
절차적으로 그리는 도형과 달리, 한 도안은 픽셀을 직접 찍어 런렝스로 담아 뒀다. 해상도를 올리자 크기가 안 맞아 바로 터졌다.
def _from_rle(rle: str, src_res: int = 32):
...
if SHAPE_RES != src_res: # 원본 해상도를 기억해 두고 정수배로 확대
s = SHAPE_RES // src_res
grid = [[v for v in row for _ in range(s)] for row in grid for _ in range(s)]
원본 해상도를 상수로 남겨 두면 나중에 해상도가 바뀌어도 그림을 다시 찍지 않아도 된다.
큰 여백은 “그림이 희박하다”로 읽힌다
아이소메트릭 에셋 241장이 전부 “너무 희박함”으로 탈락했다. 그림이 나빠서가 아니라 타일 격자에 맞춘 큰 캔버스 안에 물체가 작게 들어 있어서였다. 줄이고 나면 채움 비율이 3%였다.
받을 때 투명 여백을 잘라내고 정사각형으로 다시 맞추자 0 → 153종이 됐다.
box = img.getbbox() # 알파 기준 실제 내용 영역
if box: img = img.crop(box)
side = max(img.size) # 정사각으로 패딩 — 양자화가 정사각으로 강제 리사이즈하므로
sq = Image.new("RGBA", (side, side), (0, 0, 0, 0))
sq.paste(img, ((side - img.width) // 2, (side - img.height) // 2))
비율 기반 게이트는 피사체 크기가 아니라 캔버스 대비 비율을 보므로, 여백이 많은 소스는 자르지 않으면 통째로 탈락한다.
성능
칸마다 그리면 1000장에서 멈춘다 — 픽셀 데이터를 한 번에 넣는다
도안 목록이 1000장을 넘자 페이지가 한참 멈췄다. 칸 하나씩 fillRect를 부르고 있었는데, 1000장 × 4096칸 = 400만 번이었다.
네이티브 해상도로 ImageData를 만들어 한 번에 넣고, 확대는 CSS에 맡기면 도안당 4096번이 1번이 된다.
function paint(cv, colorAt) {
cv.width = cv.height = RES; // 64×64 원본 크기로 그린다
const c = cv.getContext('2d'), img = c.createImageData(RES, RES), D = img.data;
for (let i = 0; i < RES * RES; i++) {
const p = rgb(colorAt(i));
D[i*4] = p[0]; D[i*4+1] = p[1]; D[i*4+2] = p[2]; D[i*4+3] = 255;
}
c.putImageData(img, 0, 0); // 확대는 CSS가
}
canvas { width: 100%; image-rendering: pixelated; }
image-rendering: pixelated 덕에 작은 캔버스를 늘려도 뿌옇게 번지지 않고 픽셀이 또렷하게 유지된다.
화면에 들어온 것만 그리면 목록 길이와 무관해진다
여기에 더해, 스크롤로 보이는 카드만 그리게 했다.
const seen = new IntersectionObserver((es) => {
for (const e of es) if (e.isIntersecting) { seen.unobserve(e.target); e.target._draw(); }
}, {rootMargin: '400px'});
function lazy(cv, draw) {
cv.width = cv.height = RES; // 그리기 전에도 자리를 차지해야 카드가 안 겹친다
cv._draw = draw; seen.observe(cv); return cv;
}
빈 캔버스는 높이가 0이라 카드가 전부 한 자리에 겹쳐 동시에 관찰자에 걸린다. 그리기 전에 크기를 먼저 잡아 줘야 지연 렌더가 실제로 동작한다.
난이도 측정
자동 측정과 사람 체감이 맞는지부터 확인한다
봇이 잰 클리어율을 믿고 난이도를 배정하고 있었는데, 그 값이 실제 체감과 맞는지는 확인한 적이 없었다. 아홉 판을 직접 플레이해 대조하니 순서가 정확히 일치했다.
| 봇 클리어율 | 체감 |
|---|---|
| 0.71 | 딱히 생각 안 해도 됨 |
| 0.64~0.57 | 생각은 해야 하나 쉬움 |
| 0.36 | 쉽고 적당함 |
| 0.31 | 재밌다 |
| 0.25 | 멈칫하게 됨 |
측정 지표를 계속 쓰려면 사람 판단과 한 번은 맞춰 봐야 한다. 맞는 걸 확인하고 나서야 그 값으로 자동 판정을 늘릴 수 있다.
오차 허용은 방향에 따라 달라야 한다
목표를 벗어나도 얼마까지는 봐주는 기준을 두는데, 양쪽 다 0.15로 같게 뒀더니 벗어난 201개 중 194개가 그냥 통과했다.
체감을 들어 보니 방향에 따라 의미가 달랐다.
- 목표보다 어려운 판 → “적당히 신경 쓰이는” 좋은 판
- 목표보다 쉬운 판 → 그대로 싱거움
봇은 실수를 섞어 두므로 사람보다 비관적이다. 그래서 어려운 쪽 오차는 실제로 더 좁고, 쉬운 쪽 오차는 고스란히 싱거움이 된다.
TOL_HARDER = 0.15 # 목표보다 어려움 — 넉넉히 허용
TOL_EASIER = 0.05 # 목표보다 쉬움 — 밴드 폭만큼만
평가자가 누구인지가 결과보다 중요할 때가 있다
확인해 준 사람이 최고 난도 판을 아이템 없이 네 번에 깨는 수준이었다. 그러면 같은 피드백이라도 신뢰도가 갈린다.
- “싱겁다” → 믿을 수 있다. 고수가 싱겁다면 누구에게나 싱겁다
- “안 어렵다” → 못 쓴다. 일반 플레이어에겐 충분히 어려울 수 있다
한쪽 신호만 근거로 삼았다. 처음엔 “안 어렵다”를 근거로 난이도 곡선 전체를 올리려 했는데, 그대로 갔으면 일반 플레이어에게 벽을 세울 뻔했다.
측정을 싸게 하려고 줄이면 측정 대상이 달라진다
난이도를 잴 때 그림을 절반으로 줄여 쓰면 계산이 4분의 1로 준다. 그런데 줄이는 방식이 한 칸씩 건너뛰어 뽑기라 얇은 선이 사라진다.
예전에는 원본이 32이고 캔버스가 그걸 두 배로 확대한 것이라, 확대는 한 칸을 2×2로 복제할 뿐이어서 구조가 완전히 같았다. 측정용 그림이 실제 판과 같았던 것이다. 해상도를 올리면서 이 관계가 깨졌다.
PROBE_DIV = 2 # 측정은 절반 크기로 — 빠르지만 실제 판과 구조가 달라진다
다행히 순서는 유지된다는 걸 실기기로 확인했지만, 빠르게 하려고 줄인 것이 재는 대상 자체를 바꾸지 않는지는 따로 확인해야 한다.
상태 관리
장부만 지우면 실물은 남는다
붓는 소리가 안 멈추고 계속 나는 버그가 있었다. 설정에서 꺼도, 앱을 나가도 안 멈추고 강제종료해야만 꺼졌다.
소리를 바구니별로 관리하는데 두 개의 장부를 쓴다 — 재생 중인 것(_pourById), 서서히 줄이는 중인 것(_pourFades). 전체를 정리할 때 이렇게 했다.
// ❌ 타이머만 취소하고 소리는 안 껐다
for (final t in _pourFades.values) { t.cancel(); }
_pourFades.clear(); // 여기서 실물이 어느 장부에도 없게 된다
for (final p in _pourById.values) { p.stop(); }
줄이는 중이던 소리는 이미 _pourById에서 빠져 있다. 타이머를 취소하면 p.stop()을 부를 주체가 사라지고, 장부에서도 지워지니 다시 찾을 방법이 없는 미아가 되어 영원히 반복 재생된다.
// ✅ 장부가 아니라 실물 전체를 끈다
for (final t in _pourFades.values) { t.cancel(); }
_pourFades.clear();
_pourById.clear();
for (final p in _pourPool) { p.stop(); } // 장부가 새도 소리는 확실히 끊긴다
정리(cleanup)는 장부가 아니라 실물을 기준으로 해야 한다. 장부는 새기 마련이고, 새는 순간 복구 경로가 사라진다.
끄는 스위치는 플래그만 내려선 안 된다
효과음을 끄는 함수가 플래그만 내리고 있었다. 이미 재생 중인 반복음은 그대로 남는다.
// ❌ void setSfxEnabled(bool on) => _sfxOn = on;
void setSfxEnabled(bool on) {
_sfxOn = on;
if (!on) stopAllPour(); // 지금 나고 있는 것도 끊어야 한다
}
플래그는 다음 재생을 막을 뿐이다. 반복 재생 중인 것에는 아무 영향이 없다.
상태 동기화는 변화가 있을 때만 하면 놓친다
붓는 소리를 맞추는 코드가 “모래가 부어질 때”만 돌고 있었다. 그러면 붓기가 끝난 순간에는 안 돌아서 소리를 끌 기회를 놓친다.
// ❌ if (grains > 0) { ...; syncPour(state.pouringIds()); }
if (grains > 0) { ... }
syncPour(state.pouringIds()); // 변화가 없는 프레임에도 맞춘다
“바뀔 때만 동기화”는 바뀌지 않게 된 것을 못 잡는다. 목표 상태와 현재 상태를 맞추는 코드는 매번 도는 게 맞다.
도구와 환경
이름 공간이 겹치면 조용히 덮어쓴다
새 소재를 넣을 때 파일명이 apple·fish처럼 기존 이모지 도안과 겹쳤다. 이름이 유일 키라 겹치면 하나가 조용히 사라진다.
접두어로 갈랐다. 기존 규칙(u로 시작하면 이모지, kr_이면 직접 그린 것)과 안 겹치게 k_를 골랐다.
stem = re.sub(r"_(?:NE|NW|SE|SW)$", "", stem) # 회전 접미 제거
stem = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", stem) # bathroomCabinet → bathroom_Cabinet
name = "k_" + re.sub(r"[^a-z0-9]+", "_", stem.lower())
통째로 다시 쓰는 함수는 남의 데이터를 지운다
도안 파일을 만드는 함수가 매번 파일을 통째로 덮어쓰고 있었다. 다른 경로로 등록한 도안은 다음 실행 때 흔적도 없이 사라진다.
# 자기가 만들지 않은 항목은 살려서 함께 쓴다
noto_names = set(EMOJI_NAMES.values())
preserved = [d for d in json.load(f) if d["name"] not in noto_names]
json.dump(shapes + preserved, f, ...)
파일 하나를 여러 생산자가 공유하면, 각자 자기 몫만 갱신하고 나머지는 보존해야 한다.
Windows 콘솔에서 파이썬 출력이 죽는 문제
작업은 다 끝났는데 마지막 출력 한 줄에서 프로그램이 죽었다. 콘솔 기본 인코딩이 cp949라 — 같은 글자를 못 찍는다.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
errors="replace"까지 줘야 예상 못 한 글자에서도 안 죽는다.
출시 준비
개인정보처리방침은 실제 수집과 어긋나면 그 자체가 위반이다
블로그에 있던 방침에 “앱은 제3자 분석·광고·추적을 쓰지 않는다”고 단정돼 있었다. 게임에 광고를 붙이면서 이 문장이 거짓이 됐다. 광고 SDK는 광고 ID를 수집한다.
문장을 지우지 않고 범위를 좁혔다 — “광고가 없는 앱은”. 다른 앱에 한 기존 약속은 지키면서 게임만 예외가 되게.
문서만 고쳐서는 부족하다. 스토어의 데이터 보안 양식에도 같은 내용을 신고해야 하고, 둘이 어긋나면 그것 자체가 위반이다.
살 수 없는 유료 상품은 화면에서 빼야 한다
상점에 “₩5,500” 가격과 할인 표시까지 붙은 카드가 “준비 중”인 채로 남아 있었다. 코드에만 있고 화면엔 안 나오는 상태였지만, 그대로 두면 실수로 노출되기 쉽다.
가격이 붙은 채 살 수 없는 상품은 오해를 유발하는 표시로 문제가 된다. 결제를 붙이지 않기로 했으니 카드를 지우고, 나중에 이어 붙일 상수와 지급 함수만 남겼다.