sprite.y는 어디를 가리키는가
Phaser에서 스프라이트의 y는 기본적으로 프레임 셀의 중심입니다. 여기서 셀과 그림을 구분해야 하는데, 셀은 스프라이트 시트를 등분한 463x409짜리 칸이고 그림은 그 안에 실제로 그려진 보스입니다. 우리 보스 시트는 그림이 셀 아래쪽에 몰려 있어서, 보스를 바닥에 세우면 sprite.y는 대략 보스의 가슴 높이가 됩니다.
아래는 보스 등장 시트에 셀 경계선을 그려본 그림입니다. 그림 위쪽에 빈 공간이 꽤 있다는 게 보이실 겁니다. 이 여백이 이번 글의 주인공입니다.

1배 스케일에서는 셀 중심과 그림의 실제 중심이 어긋나 봐야 수십 픽셀이라 대충 맞아 보였습니다. 2배로 키우니 어긋남도 함께 2배가 됐고요. 문제는 보스의 모든 공격 판정이 sprite.y 기준으로 생성되고 있었다는 점입니다. 검격 히트박스도 탄막의 총구도 전부 가슴 높이에서 나갔습니다. 바닥에 서 있는 플레이어는 그보다 한참 아래에 있으니, 히트박스가 머리 위로 지나가면서 명중이 안 났습니다. 화면만 보면 "보스 공격이 안 맞는다"인데, 실체는 "기준점이 위로 올라갔는데 아무도 모르고 있었다"였죠.
// 스프라이트 원점(셀 중심) 대신, 바닥 기준의 공격 높이 하나를 정의
private get attackY(): number {
return this.arena.bounds.floorY - 60;
}
// 검격, 탄막, 사슬, 돌진, 휘두르기 다섯 패턴이 전부 이 값을 쓴다
this.spawnHitbox(hitX, this.attackY, reach, SLASH.height, SLASH.activeMs);고치는 방법으로 패턴마다 오프셋을 손볼 수도 있었지만, 그러면 다음에 스케일이 바뀔 때 다섯 군데를 또 고쳐야 합니다. 대신 바닥에서 얼마라는 단일 기준점 attackY를 만들어 모든 패턴이 그것만 보게 했습니다. 기준점이 하나면 고칠 곳도 하나입니다.
bottom-anchor 이펙트에 중심 좌표를 넘기면
보스 등장 연출에는 그림자 덩어리에서 본체가 솟아오르는 실체화 이펙트가 있습니다. 어느 날 보니 이 이펙트가 바닥에서 260픽셀쯤 공중에 떠서 재생되고 있었습니다. 땅에서 솟아야 할 게 허공에서 솟으니 상당히 초현실적인 그림이 됩니다.
이펙트 스프라이트는 원점을 그림의 바닥에 맞춰둔 bottom-anchor 방식이었습니다. setOrigin(0.5, 0.98) 같은 식으로요. y 인자로 바닥선을 받아서 거기서부터 위로 솟아오르라고 만든 함수입니다. 그런데 호출부에서 넘긴 값이 바닥선이 아니라 보스의 sprite.y, 그러니까 아까 그 가슴 높이였습니다. 함수는 받은 y를 그림의 바닥으로 삼으니 이펙트 전체가 가슴 높이 위에 세워진 거죠. 같은 시트의 다른 이펙트들, 페이즈 오오라나 지면 가시는 floorY를 제대로 넘기고 있어서 멀쩡했고 실체화 하나만 어긋나 있었습니다.
수정 자체는 인자를 floorY - 6으로 바꾸는 한 줄입니다. 하지만 이 한 줄짜리 버그는 함수 시그니처의 y: number가 아무것도 보장해 주지 않는다는 걸 알려줍니다. 이 y가 중심 기준인지 바닥 기준인지는 타입에 없습니다. 주석에라도 있어야 하는데 그 주석마저 없었습니다. 고치면서 호출부에 "이펙트는 바닥 원점이라 y에 바닥선을 줘야 한다"는 주석을 남겼습니다. 다음 사람은 대부분 미래의 저니까요.
셀의 여백이 프롬프트를 하늘에 걸었다
보스는 처음에 그림자 상태로 서 있고, 플레이어가 다가가서 상호작용해야 깨어납니다. 이 상호작용 안내 프롬프트가 보스 머리 위가 아니라 하늘에 둥둥 떠 있는 문제가 있었습니다.
프롬프트 위치는 boss.y - displayHeight / 2 - 20, 그러니까 스프라이트 위 끝에서 20픽셀 위로 잡고 있었습니다. 논리적으로는 맞아 보입니다. 함정은 displayHeight가 그림이 아니라 셀의 높이라는 점입니다. 아까 본 그림처럼 셀 409픽셀 중 그림은 아래쪽 340픽셀 정도고 위는 빈 여백인데, 2배 스케일이 되면서 그 여백도 2배가 됐습니다. 그래서 프롬프트가 보스 머리에서 140픽셀쯤 위에 떴습니다.
고칠 방법은 두 가지가 있었습니다. 그림의 실제 상단을 표로 관리하거나, 이미 그림 실측에 맞춰 잡아둔 물리 바디의 상단인 body.top을 빌리거나. 앞엣것은 시트가 바뀔 때마다 표를 갱신해야 하는 유지비가 듭니다. 11편에서 다룬 실측표 사건을 생각하면 표는 더 만들고 싶지 않았고요. 뒤엣것은 히트박스를 정렬하면서 이미 검증된 값을 재사용하는 거라 공짜입니다. body.top - 20으로 바꿨습니다. 참고로 이 프롬프트는 나중에 팀원 의견으로 보스가 아니라 플레이어 머리 위를 따라다니는 방식으로 바뀌었는데, 셀 크기와 그림 크기는 다르다는 교훈은 그대로 남았습니다.
비슷한 맥락에서 플레이어 스프라이트도 프레임 안 발끝 위치를 footY라는 상수로 관리합니다. 시트를 교체할 때마다 이 선에 발이 닿는지 확인합니다. 아래처럼요.

카메라 줌은 scrollFactor 0도 봐주지 않는다
마지막 증상은 UI였습니다. 이 게임에는 플레이어가 가만히 서 있으면 카메라가 천천히 확대되는 연출이 있는데, 확대가 걸리면 화면 우상단의 미니맵과 좌하단의 디버그 좌표 표시가 화면 밖으로 밀려났습니다. 둘 다 setScrollFactor(0)을 걸어둔 화면 고정 UI인데도요.
Phaser의 scrollFactor 0은 카메라의 스크롤만 무시하게 해줍니다. 줌은 그대로 적용됩니다. 줌은 화면 중앙을 기준으로 장면 전체를 확대하기 때문에 중앙에서 먼 구석의 UI일수록 바깥쪽으로 밀려나 잘립니다. 화면 좌표 x에 있는 scrollFactor 0 오브젝트가 줌 z에서 실제로 그려지는 위치는 center + (x - center) * z입니다. 이 식을 거꾸로 풀면 고정하는 보정이 나옵니다.
/** 카메라 줌을 역보정해 scrollFactor 0 오브젝트를 화면 좌표 (x, y)에 고정한다. */
private pinToViewport(obj: Graphics | Text, x: number, y: number): void {
const cam = this.cameras.main;
const inv = 1 / cam.zoom;
obj.setScale(inv); // 커진 만큼 줄이고
obj.setPosition( // 중앙 기준 거리도 1/zoom으로 되돌린다
cam.width / 2 + (x - cam.width / 2) * inv,
cam.height / 2 + (y - cam.height / 2) * inv,
);
}update에서 매 프레임 이 보정을 돌리면 줌이 트윈으로 부드럽게 변하는 중에도 미니맵과 좌표 표시가 제자리에 붙어 있습니다. 정석대로라면 UI 전용 카메라를 하나 더 만들어서 게임 오브젝트는 무시하게 하는 방법도 있습니다. 다만 이 씬은 적, 투사체, 이펙트가 수시로 생겼다 사라져서 무시 목록을 관리하는 비용이 만만치 않았습니다. 두 오브젝트에 역보정 거는 쪽이 훨씬 쌌습니다.
정리, 좌표에는 항상 기준이 따라붙는다
네 가지 버그의 공통점은 좌표 값 자체는 전부 멀쩡했다는 겁니다. 어긋난 건 값이 아니라 해석이었습니다. sprite.y를 보스의 위치라고 부르는 순간 그게 셀 중심인지 발끝인지가 흐려집니다. 이펙트 함수의 y가 중심 기준인지 바닥 기준인지는 시그니처만 봐서는 알 수 없습니다. displayHeight는 그림이 아니라 셀을 잽니다. scrollFactor 0은 줌까지 막아주지 않습니다.
그래서 이번 작업 이후로 규칙을 하나 세웠습니다. 좌표를 주고받는 함수를 만들거나 부를 때, 그 좌표의 기준을 반드시 이름이나 주석에 남기기. attackY처럼 기준이 이름에 박힌 값 하나가 패턴 다섯 개에 흩어진 오프셋 보정보다 훨씬 오래 살아남았습니다.
문제의 보스전은 여기서 플레이할 수 있습니다. 이제 공격도 맞고, 이펙트도 땅에서 솟고, 미니맵도 제자리에 있습니다.
게임 플레이하기GitHub 저장소