TECH NOTE · DESKTOP AI PET
스프라이트 시트를 행·열 계산으로 읽으면 프레임이 한 행을 넘는 순간 조용히 틀립니다. 걷는 동작이 절반에서 튀는 원인과 명시 좌표로 바꾼 이유를 실제 매니페스트와 함께 정리했습니다.
걷는 애니메이션이 절반에서 처음으로 튄다
지난 편에서 창을 만들었다. 이번 편은 그 창 안에 무엇을 그릴지다.
캐릭터는 스프라이트 시트 한 장에 들어 있다. 왜 스프라이트 시트를 골랐는지는 앞선 글에 적었고, 이번 편은 그 시트를 어떻게 읽는지다.
처음 규칙은 단순했다. 행 하나가 상태 하나, 열이 프레임이다. 그러면 좌표는 곱셈으로 나온다.
x = 프레임번호 × 셀너비
y = 행번호 × 셀높이
idle은 행 0, walk는 행 1, jump는 행 2. 매니페스트에는 상태마다 행 번호와 프레임 수만 적으면 된다.
그러다 걷는 동작이 어색해서 프레임을 4장에서 8장으로 늘렸다. 한 행에는 4장밖에 안 들어간다.
이번 편의 코드다.
https://github.com/bluecafe/pet-reference/tree/main/sprite-sheet
시트를 다시 만들지 않았다
8프레임을 한 행에 넣으려면 시트 전체를 8열로 다시 짜야 한다. 그러면 4프레임짜리 상태 서른 개가 전부 절반을 비운 채로 남는다.
그래서 남는 행에 뒷부분을 이어 붙였다. walk의 앞 4프레임은 원래 자리에 두고, 뒤 4프레임은 시트 끝의 빈 행에 넣었다.
실제 앱의 매니페스트를 열어 보면 이렇게 되어 있다.
walk 0번 행 1 (y=512)
3번 행 1
4번 행 34 (y=17408) ← 여기서 건너뛴다
7번 행 34
시트가 36행인데 walk의 뒷부분이 34행에 있다. 중간 서른 몇 행을 건너뛴다.
계산식이 어디서 깨지나
곱셈으로 좌표를 구하는 코드는 이렇게 생겼다.
export function computeFrames(
manifest: SpriteManifest,
state: string,
): FrameRect[] | null {
const playback = manifest.playback[state];
if (!playback) return null;
const { cellWidth, cellHeight } = manifest.geometry;
return Array.from({ length: playback.frames }, (_, index) => ({
x: (index % manifest.columns) * cellWidth,
y: playback.row * cellHeight,
w: cellWidth,
h: cellHeight,
}));
}
문제는 index % manifest.columns 한 곳이다. 열 수로 나눈 나머지를 쓰니 프레임 번호가 열 수를 넘는 순간 앞으로 돌아간다.
walk의 5번째 프레임(번호 4)을 넣어 보면 이렇게 된다.
x = (4 % 4) × 512 = 0
y = 1 × 512 = 512
1번째 프레임과 정확히 같은 자리다. y도 playback.row만 보므로 행은 아예 바뀌지 않는다.
화면에서는 이렇게 보인다. 걷는 동작이 네 장까지 진행되다가 갑자기 첫 장으로 튀고, 다시 네 장을 반복한다. 8프레임을 그렸는데 4프레임만 도는 것처럼 보인다.
예제를 돌리면 계산과 실제가 갈라지는 지점이 그대로 나온다.
idle 4프레임 같음
walk 8프레임 다름 ← 계산으로 못 찾는다
jump 4프레임 같음
전부 좌표를 적어 둔다
고치는 방법은 두 가지였다.
계산을 쓰되 예외를 적어 두기 — walk만 특별 취급한다. 코드는 적게 바뀌지만, 이후로 상태를 볼 때마다 “이건 계산이었나 예외였나”를 확인해야 한다. 예외가 하나에서 멈춘다는 보장도 없다.
전부 좌표를 적기 — 매니페스트가 길어진다. 대신 읽는 쪽은 한 가지 방법만 쓴다.
후자를 골랐다. 한 상태라도 계산과 어긋나면 계산을 믿을 수 없다.
그래서 매니페스트에 두 벌이 들어간다.
{
"playback": {
"idle": { "row": 0, "frames": 4, "fps": 3, "loop": true },
"walk": { "row": 1, "frames": 8, "fps": 8, "loop": true }
},
"layout": {
"idle": [
{ "x": 0, "y": 0, "w": 512, "h": 512 },
{ "x": 512, "y": 0, "w": 512, "h": 512 },
{ "x": 1024, "y": 0, "w": 512, "h": 512 },
{ "x": 1536, "y": 0, "w": 512, "h": 512 }
],
"walk": [
{ "x": 0, "y": 512, "w": 512, "h": 512 },
{ "x": 512, "y": 512, "w": 512, "h": 512 },
{ "x": 1024, "y": 512, "w": 512, "h": 512 },
{ "x": 1536, "y": 512, "w": 512, "h": 512 },
{ "x": 0, "y": 17408, "w": 512, "h": 512 },
{ "x": 512, "y": 17408, "w": 512, "h": 512 },
{ "x": 1024, "y": 17408, "w": 512, "h": 512 },
{ "x": 1536, "y": 17408, "w": 512, "h": 512 }
]
}
}
playback은 어떻게 재생할지, layout은 어디에 있는지다. walk의 다섯 번째 항목에서 y가 512에서 17408로 뛰는 것이 보인다. 계산으로는 나올 수 없는 값이다.
읽는 쪽은 layout만 본다.
export function resolveFrames(
manifest: SpriteManifest,
state: string,
): readonly FrameRect[] | null {
return manifest.layout[state] ?? null;
}
곱셈이 사라졌다. playback.row는 재생 정보에 남아 있지만 그리는 데는 안 쓴다. 어느 행에서 시작하는지를 사람이 볼 때만 쓴다.
같은 그림을 두 번 넣지 않는다
매니페스트를 보다가 알게 된 것이 하나 더 있다. talking과 wave가 같은 행을 가리킨다.
말할 때와 손 흔들 때 같은 동작을 쓰기로 했으면 그림을 두 번 넣을 이유가 없다. 512×512 프레임 네 장이면 시트가 그만큼 커지고, 그 시트는 앱 바이너리 안에 들어간다.
이름만 따로 두고 좌표를 공유한다. 나중에 말하는 동작을 따로 그리게 되면 그때 좌표만 바꾸면 된다. 부르는 쪽 코드는 그대로다.
예제의 findAliases()가 같은 행을 가리키는 상태를 찾아낸다.
어긋난 좌표는 화면에서야 드러난다
매니페스트가 틀리면 실행해야 보인다. 캐릭터가 반쯤 잘리거나 빈 칸이 그려지는데, 원인이 매니페스트라는 걸 알아채기 어렵다. 그림 파일을 먼저 의심하게 된다.
그래서 검사를 따로 뒀다. 네 가지를 본다.
frames.forEach((rect, index) => {
if (rect.w !== cellWidth || rect.h !== cellHeight) {
errors.push({ kind: "size-mismatch", state, frame: index });
}
if (
rect.x < 0 || rect.y < 0 ||
rect.x + rect.w > sheetWidth ||
rect.y + rect.h > sheetHeight
) {
errors.push({ kind: "out-of-bounds", state, frame: index });
}
});
네 가지를 본다.
| 검사 | 잡는 것 |
|---|---|
| 프레임 수 | 선언한 수와 좌표 개수가 다르다 |
| 시트 경계 | 시트 밖을 가리킨다 |
| 셀 크기 | 셀 크기와 다른 프레임이 있다 |
| 좌표 누락 | 재생 정보는 있는데 좌표가 없다 |
시트 경계 검사가 특히 쓸모 있다. 행을 추가하다가 시트 높이를 안 늘리면 마지막 상태가 통째로 밖을 가리키는데, 이 검사가 없으면 그 상태가 재생될 때까지 모른다.
안 쓰는 행도 찾는다
walk의 뒷부분을 옮기다 보면 중간에 빈 행이 생긴다. 예제에 그것도 찾는 함수를 넣었다.
빈칸은 그냥 비어 있는 게 아니라 낭비다. 512×512 한 행이면 시트에서 그만큼 자리를 차지하고, 그 시트는 앱에 통째로 들어간다.
실제 앱의 시트에도 안 쓰는 행이 하나 있었다. walk를 옮기면서 생긴 자리다.
정리
스프라이트 시트를 규칙으로 읽으려면 그 규칙이 언제 깨지는지를 먼저 정해야 한다.
- 행·열 계산은 한 행에 들어갈 때만 맞다
- 프레임이 늘어 행을 넘으면 계산이 조용히 틀린다. 오류가 안 난다
- 예외를 적어 두는 대신 전부 좌표를 적는다. 한 가지 방법만 쓴다
- 같은 그림을 쓰는 상태는 좌표를 공유한다
- 매니페스트를 검사하지 않으면 화면에서야 드러난다
- 안 쓰는 행은 시트를 그만큼 키운다
돌아가는 코드는 pet-reference/sprite-sheet에 있다. 매니페스트 표본에 walk가 끊어진 것과 별칭이 실제 그대로 들어 있어서, 계산 방식이 어디서 깨지는지 직접 볼 수 있다.
앱에서 캐릭터가 걷는 것을 보면 8프레임이 도는 것이다. 4프레임으로 튀지 않는다.
다음 편은 그 시트를 만드는 쪽이다. 프레임 수백 장을 같은 규격으로 맞추고, 중간에 깨진 파일을 어떻게 찾아내는지 다룬다.
