dotgimon 제작기 07. 32px 도트 변환과 스프라이트 생성
표기 날짜는 작업 순서에 맞춰 배치한 날짜다. 실제 작업일은 확인되지 않으며, 관련 변경은 9월 30일에 저장소에 한꺼번에 남았다.
dotgimon의 몬스터는 16×16 도트맵을 두 배로 확대해 그렸다. 출력 이미지는 36×36이었지만 실제로 그릴 수 있는 칸은 16×16이었다. 종마다 새 원화를 만들면서 자동 변환과 수동 보정을 함께 쓸 수 있는 제작 과정을 만들었다.
새 원본은 32×32 흑백 도트로 정했다. 확대 없이 사방에 2px 여백만 붙여 같은 36×36 프레임으로 만들었다. 화면에 올리는 파일 크기 규격은 유지하면서 원본 격자의 칸 수를 네 배로 늘렸다.
같은 그림은 다시 만들어도 같은 도트가 나오게 했다
입력은 종마다 만든 컬러 PNG 원화이고 출력은 애니메이션 프레임을 가로로 이어 붙인 PNG였다. 원화와 변환 결과, 프롬프트를 종별 리비전 폴더에 모았다. 새 디자인은 리비전을 나눠 이전 원화를 구별했다.
자동 변환은 같은 원화와 같은 규칙이면 같은 결과를 내도록 만들었다. 변환 규칙을 고쳤을 때 어떤 종의 도트가 달라졌는지 비교하려면, 실행할 때마다 임의로 달라지는 요소를 두지 않는 편이 맞았다.
손으로 고친 도트는 pixels.txt에 32행×32칸으로 저장했다. #은 검정, .은 흰색이었다. 이 파일이 있으면 원화 변환을 건너뛰고 보정본을 읽었다. 자동 생성기가 사람의 수정을 덮어쓰지 않도록 우선순위를 파일 존재 여부로 정했다.
원화, 자동 변환본, 수동 보정본을 한 파일로 합치지 않았다. 어떤 도트가 변환 규칙에서 나온 것인지, 사람이 따로 손본 것인지 파일만 보고도 구별할 수 있었다.
아트 목록이 게임 도감과 어긋나면 멈췄다
빌드 스크립트는 카탈로그의 ID 목록과 실제 도감의 ID 목록을 비교했다. 몬스터 수를 고정 숫자로 적지 않고 두 집합이 같은지 검사했다. 각 종의 단계와 속성도 도감과 맞췄다.
assert.equal(catalog.logicalSize, 32)
assert.equal(catalog.outputSize, 36)
assert.equal(
new Set(catalog.monsters.map(m => m.id)).size,
catalog.monsters.length,
)
assert.deepEqual(
catalog.monsters.map(m => m.id).sort(),
Array.from(dex.DEX, m => m.id).sort(),
)
for (const m of catalog.monsters) {
assert.equal(m.stage, dex.BY_ID[m.id].stage)
assert.equal(m.attr, dex.BY_ID[m.id].attr)
}이 검사는 ID 중복과 누락, 단계·속성 불일치를 잡았다. 표시 이름까지 검사하는 것은 아니었다. TypeScript로 작성한 도감은 변환해서 Node의 vm에서 읽어 왔다. 아트용 사본을 다시 규칙의 원본처럼 쓰지 않으려는 연결이었다.
밝기만 자르면 흰 배가 배경이 되었다
원화에는 색과 부드러운 외곽선이 있었고 배경은 흰색이었다. 밝기만 기준으로 흑백을 나누면 흰 눈이나 배를 배경과 구별하기 어려웠다. 검정으로 남길 후보와 몸의 경계를 찾는 후보를 나눴다.
알파가 있는 픽셀은 흰 배경 위에 합성한 색으로 계산했다. 밝기가 낮으면 잉크 후보로 잡고, 덜 밝거나 색상 차이가 있으면 형태를 찾는 마스크에 넣었다.
const lum = .2126 * r + .7152 * g + .0722 * b
ink[i] = +(lum < 140)
shape[i] = +(lum < 236 || Math.max(r, g, b) - Math.min(r, g, b) > 22)순수한 흰색은 이 shape 조건에도 들어가지 않는다. 흰 내부를 몸의 일부로 구별하는 일은 다음 단계에서 했다. 이미지 가장자리부터 형태 마스크가 아닌 칸을 네 방향으로 따라가며 바깥 영역을 표시했다. 닫힌 윤곽 안에 갇힌 흰 영역은 이 탐색이 닿지 않아 바깥과 구별되었다.
몸 전체를 감싸는 최소 사각형인 바운딩 박스를 구한 뒤, 그 영역을 32×32 격자로 나누었다. 각 칸에 해당하는 원화 픽셀 중 몸 영역 비율이 38% 이상이면 몸으로, 어두운 비율이 35% 이상이면 검정으로 잡았다. 외곽을 판단하는 값과 채움을 판단하는 값을 분리했다.
몸으로 잡힌 칸의 가장자리는 안쪽 명암과 상관없이 검게 만들었다. LCD에서는 흰색이 배경색으로 합성되기 때문에 테두리가 열려 있으면 몸 안의 흰 부분이 밖과 이어져 보였다. 검정 외곽을 닫고 안쪽 흰 영역은 남기는 쪽으로 정했다.
테스트에는 속이 빈 사각 테두리를 넣었다. 변환 뒤 가운데가 검정으로 전부 채워지지 않고 흰색으로 남는지 확인하는 사례였다. 원화가 줄어들어도 내부의 빈 부분을 보존하는 규칙을 검사했다.
단계별 크기도 변환 규칙에 넣었다
같은 32×32 안에 가득 채우면 유년기와 완전체의 덩치 차이가 줄었다. 초기 변환에서는 긴 변이 캔버스에서 차지하는 비율을 단계별로 정했다. 어린 종은 작게, 높은 단계는 더 크게 들어가도록 했다.
크기를 맞추는 기준과 원본 격자 크기는 별개였다. 32×32가 저장 형식이고, 그 안에서 몸이 차지할 면적은 진화 단계에 따라 달랐다. 도트가 프레임 밖으로 나가지 않는 여백과 애니메이션에 쓸 공간도 함께 남겼다.
두 프레임을 한 PNG로 이어 붙였다
32px 도트는 pad32로 36px 프레임 가운데에 넣었다. 이 함수는 확대하지 않고 여백만 붙였다. 세로 이동값 dy를 받되, 움직인 결과가 원본 범위 밖으로 나가면 예외를 던졌다.
export function pad32(pixels, dy = 0) {
const out = new Uint8Array(1296)
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
if (pixels[y * 32 + x]) {
assert(y + dy >= 0 && y + dy < 32, 'Animation would clip')
out[(y + dy + 2) * 36 + x + 2] = 1
}
}
}
return out
}대기 모션은 원래 위치와 한 칸 위로 옮긴 위치의 두 프레임이었다. 맨 윗줄에 이미 검정 칸이 있으면 두 번째 프레임도 이동하지 않았다. 몸의 일부를 지워 누르는 방식과 달리 형태 전체를 유지한 채 위치만 바꾸었다.
몬스터는 700ms, 알은 800ms 간격으로 프레임을 바꾸는 값을 생성했다. 두 개의 36×36 프레임을 가로로 붙이면 72×36 PNG가 되었다. 엔진이 넘긴 프레임 번호로 해당 칸을 골라 그릴 수 있게 GIF에서 스트립 형식으로 옮겼다.
PNG 인코더와 디코더는 Node의 zlib을 이용해 필요한 이미지 형식을 직접 처리했다. 인코딩 후 다시 읽었을 때 흑백 값이 유지되는지, 스트립 크기가 맞는지 검사했다.
앱에는 이미지와 프레임 정보를 함께 넘겼다
생성기는 PNG 외에 앱이 읽을 정적 이미지 매핑도 만들었다. 종 ID로 이미지 파일을 찾고, 프레임 수와 간격으로 애니메이션을 계산하도록 데이터를 나눴다. 원화 미제작·도트 초안·도트 보정 상태도 빌드 결과에 남겼다.
렌더러는 엔진의 프레임 번호에 맞춰 스트립에서 현재 칸을 골랐다. 확대에는 nearest 샘플링을 사용하고 안티앨리어싱을 껐다. 이웃 프레임이 보이거나 도트 가장자리가 번지는 것을 피하려는 설정이었다.
이미지는 투명색 없이 흑백으로 만들고 multiply로 LCD 위에 합성했다. 흰색은 바탕색을 유지하고 검정은 잉크로 남았다. 변환 과정에서 몸 테두리를 닫는 규칙이 이 합성 방식과 연결되어 있었다.
빠진 그림은 빌드 결과에서 숨기지 않았다
원화가 없는 종은 기본 빌드에서 ? 그림으로 표시했다. 다른 종을 작업하는 동안 전체 빌드가 막히지 않게 하되, 해당 종이 완성됐다고 보이지는 않게 했다. 누락 목록은 build-report.json에 남겼다.
배포용 검사는 --strict 옵션으로 나눴다. 누락된 원화가 하나라도 있으면 성공하지 않도록 했다.
if (process.argv.includes('--strict') && pending.length) {
throw new Error(`Missing concepts (${pending.length}): ${pending.map(m => m.id).join(', ')}`)
}일부 종만 다시 만들 때는 --only 옵션을 썼다. 파일은 임시 파일로 쓴 뒤 이름을 바꾸고, 종별 도트 해시를 결과에 기록해 변환 규칙을 바꾼 전후를 비교하게 했다. 갤러리는 여러 종의 원화와 도트를 눈으로 확인하는 데 사용했다.
자동 빌드가 끝났다는 것과 외형이 승인됐다는 것은 다른 상태로 남겼다. 같은 규칙으로 초안을 만들고, 사람이 고친 파일은 우선 보존하고, 없는 원화는 실패 목록에 드러냈다. 도트 변환과 검수가 같은 결과 이름으로 섞이지 않게 했다.