애니메이션이 이렇게 어려웠나....
문제 상황 요약
단순히 룰렛을 돌리고 결과만 보여주는 UI가 아니라, 포인터가 핀에 걸리는 느낌, 감속하면서 자연스럽게 멈추는 동작,
그리고 당첨 결과까지 이어지는 애니메이션을 하나의 흐름으로 구현하고 싶었다.
처음에는 회전 애니메이션만 잘 만들면 될 줄 알았는데, 막상 구현해보니 핵심은 단순 회전이 아니라 아래 요소들을 얼마나 자연스럽게 연결하느냐였다.
포인터가 핀에 걸리는 느낌
감속하면서 멈출 때의 물리감
항상 같은 방향으로 자연스럽게 정지하는 것
결과 애니메이션과 룰렛 애니메이션을 끊기지 않게 연결하는 것
DOM 좌표계와 애니메이션 좌표계가 꼬이지 않게 유지하는 것
1. 핀이 “좌표”처럼 동작하는 문제
처음에는 포인터가 핀을 단순한 위치값으로만 인식했다.
그래서 핀 위에서 좌우로 흔들리기만 하고, 실제로 뭔가에 걸리는 느낌이 전혀 나지 않았다.
문제는 핀을 점처럼 다루고 있었기 때문이었다. 핀이 실제로 공간을 차지하는 오브젝트처럼 동작해야 포인터가 “접촉 → 눌림 → 넘어감”의 흐름을 만들 수 있다.
처음엔 이런 식으로 처리했다.
const pinIndex = Math.floor(rotation / slotAngle)
if (pinIndex !== lastPinIndex) {
tickPointer()
}이 방식은 핀을 “지났는지 여부”만 판단할 뿐, 핀의 폭이나 접촉 구간은 전혀 고려하지 않는다.
그래서 나중에는 핀을 단순 인덱스가 아니라 폭이 있는 영역으로 생각하는 쪽으로 바꿨다.
const PIN_HALF_WIDTH = 2.8
function angularDistance(a: number, b: number) {
return ((a - b + 540) % 360) - 180
}
function getPinContact(rot: number, pinAngles: number[]) {
for (let i = 0; i < pinAngles.length; i++) {
const d = angularDistance(rot, pinAngles[i])
if (Math.abs(d) <= PIN_HALF_WIDTH) {
return {
pinIndex: i,
ratio: (d + PIN_HALF_WIDTH) / (PIN_HALF_WIDTH * 2),
}
}
}
return null
}이렇게 바꾸고 나니 핀을 단순한 이벤트가 아니라
실제로 부딪히는 구간처럼 다룰 수 있게 됐다.
2. 감속할 때 방향이 한번 뒤집히는 문제
룰렛이 멈추기 직전에 한 번 반대로 튕겼다가 다시 돌아오는 문제가 있었다.
겉으로 보기엔 작은 차이 같지만, 이게 들어가면 전체 애니메이션이 바로 어색해진다.
원인은 정지 각도를 보정하는 과정에서 현재 각도와 목표 각도 사이를 짧은 거리로 맞추려다 보니, 감속 끝부분에서 반대 방향으로 살짝 보정이 들어가는 것이었다.
이 문제를 해결하면서 느낀 건, 룰렛은 회전 방향이 한번 정해지면 끝까지 그 방향으로만 가야 한다는 점이었다.
그래서 목표 각도 계산도 항상 양수 방향으로만 누적되게 바꿨다.
function shortestPositiveDelta(from: number, to: number) {
return ((to - from) % 360 + 360) % 360
}
const current = norm(currentRotation)
const target = norm(targetRotation)
const delta = shortestPositiveDelta(current, target)
const finalRotation = current + extraTurns * 360 + delta핵심은 “가장 짧은 거리”가 아니라 “항상 같은 방향으로 도달하는 거리”를 구해야 한다는 것이었다.
3. 핀에 “걸리는 느낌”이 없는 문제
처음 구현은 부드럽게 감속해서 멈추는 형태였다.
하지만 이렇게 하면 너무 기계적으로 보인다.
원하는 느낌은 핀마다 툭툭 걸리면서 점점 느려지는 형태였다.
처음에는 핀을 지날 때마다 yoyo 애니메이션을 줬다.
gsap.fromTo(ptrEl,
{ rotation: 0 },
{
rotation: -10,
duration: 0.03,
yoyo: true,
repeat: 1,
ease: 'power1.out'
}
)그런데 이렇게 하니 “반응”은 생겨도 “걸림”은 생기지 않았다.
그냥 한번 흔들리는 느낌에 가까웠다.
그래서 나중에는 핀을 지날 때 포인터가 '눌리고', '잠깐 유지되고', '다시 복귀하는' 3단계 구조로 바꿨다.
gsap.timeline()
.to(ptrEl, {
rotation: catchAngle,
duration: catchDur,
ease: 'power2.out',
})
.to(ptrEl, {
rotation: catchAngle,
duration: holdDur,
ease: 'none',
})
.to(ptrEl, {
rotation: 0,
duration: releaseDur,
ease: 'back.out(1.3)',
})이렇게 하니 단순 tick보다 훨씬 “핀에 걸렸다가 풀리는 느낌”이 살아났다.
4. 회전 애니메이션과 결과 애니메이션을 한 번에 처리하려고 해서 꼬인 문제
처음에는 룰렛 회전, 정지, 결과 표시까지 하나의 흐름 안에서 한꺼번에 처리하려고 했다.
그런데 이 방식은 상태가 조금만 늘어나도 바로 꼬였다.
예를 들어,
spinning 중인지
stopping 중인지
결과를 보여주는 중인지
모달이 열린 상태인지
이게 서로 섞이기 시작하면, 애니메이션이 겹치고 타이밍이 어긋나기 쉬웠다.
결국 상태를 명확하게 나누는 쪽으로 정리했다.
type WheelPhase = 'idle' | 'spinning' | 'stopping'그리고 각 phase마다 처리 로직을 분리했다.
if (phase === 'spinning') {
startInfiniteSpin()
}
if (phase === 'stopping') {
stopToTarget()
}
if (phase === 'idle') {
cleanup()
}이렇게 해두니 어떤 타이밍에 무엇이 실행되는지 훨씬 명확해졌다.
5. GSAP tween과 수동 물리 루프를 섞을 때 생기는 충돌
한동안 GSAP tween과 requestAnimationFrame 루프를 같이 썼다.
예를 들면 바퀴 회전은 GSAP로 돌리고, 포인터 반응은 별도 루프로 제어하는 식이었다.
문제는 둘이 동시에 같은 DOM을 건드리기 시작하면 미묘하게 transform이 덮어써지거나 타이밍이 꼬인다는 점이었다.
특히 아래처럼 GSAP와 직접 스타일 변경이 섞이면 결과가 자주 깨졌다.
gsap.to(wheelEl, { rotation: finalRotation })
wheelEl.style.transform = `rotate(${angle}deg)`이런 상태가 되면 누가 마지막 프레임을 먹느냐에 따라 화면 결과가 달라진다.
그래서 정리한 원칙은 간단했다.
한 요소의 위치/회전은 한 방식으로만 제어한다
GSAP가 잡는 요소는 끝까지 GSAP가 잡게 한다
수동 물리 루프가 필요한 경우는 별도 요소에만 쓴다
이 원칙을 세우고 나니 의외로 많은 문제가 같이 사라졌다.
6. 좌표계가 섞이면서 오브젝트가 순간이동하는 문제
룰렛 위에서 커진 텍스트를 결과 애니메이션으로 이어줄 때 가장 많이 꼬인 부분이 좌표계였다.
처음에는 absolute와 fixed를 섞어서 썼고, 부모가 가진 transform까지 겹치면서 같은 오브젝트인데도 갑자기 위치가 튀는 문제가 계속 발생했다.
특히 이런 패턴이 문제였다.
const rect = el.getBoundingClientRect()
ghost.style.position = 'absolute'
ghost.style.left = `${rect.left}px`
ghost.style.top = `${rect.top}px`getBoundingClientRect()는 viewport 기준인데, 이걸 absolute에 넣으면 부모 기준 좌표계와 섞여버린다.
결국 이동하는 오브젝트는 끝까지 fixed 기준으로 유지하는 쪽이 제일 안정적이었다.
const rect = slotTextEl.getBoundingClientRect()
gsap.set(rewardGhost, {
position: 'fixed',
left: rect.left + rect.width / 2,
top: rect.top + rect.height / 2,
xPercent: -50,
yPercent: -50,
})중간에 좌표계를 바꾸지 않는 게 중요했다.
7. 애니메이션이 어색했던 진짜 이유는 “효과 부족”이 아니라 “타이밍” 문제였다
처음에는 glow를 더 넣고, scale을 더 키우고, 이펙트를 더 추가하면 해결될 줄 알았다.
그런데 실제로 문제는 효과가 아니라 타이밍이었다.
예를 들어 보상 텍스트가 커질 때도,
얼마나 빨리 커지는지
커진 뒤 얼마나 멈추는지
흡수가 언제 시작되는지
이 세 가지가 안 맞으면 바로 어색해졌다.
결국 가장 자연스러웠던 리듬은 아래에 가까웠다.
팝업: 짧고 강하게
hold: 생각보다 길게
흡수: 팝업보다 느리게
마무리: 짧게
즉, 애니메이션을 많이 넣는 것보다 각 구간의 길이를 다르게 가져가는 게 훨씬 중요했다.
8. 결과적으로 느낀 점
룰렛 UI를 구현하면서 제일 크게 느낀 건, 이런 인터랙션은 회전만 잘 만든다고 끝나는 게 아니라는 점이었다.
오히려 중요한 건 '오브젝트를 어떻게 인식하고 있는지', '상태를 어떻게 나누는지', '좌표계를 얼마나 일관되게 유지하는지', '애니메이션 타이밍을 어떻게 배치하는지' 같은 기본기 쪽이었다.
겉보기에는 “효과” 문제처럼 보여도 막상 파고들면 대부분 구조와 제어 방식 문제였다.
결국 눈에 보이는 결과는 애니메이션이지만, 그걸 자연스럽게 만들기 위해서는
상태 관리, 좌표계, 타이밍 같은 기본적인 부분을 먼저 제대로 잡아야 한다는 걸 느꼈다.
댓글
댓글이 없습니다.
