ํ ๋ผ์ ๊ฑฐ๋ถ์ด ๊ฒฝ์ฃผ๊ฒ์์ ๋ง๋ค์๋ค.
๊ธฐ์กด์ ์๋ CSS๋ณด๋ค ๋ ์๋๊ฐ์๊ฒ ๋ง๋ค๊ธฐ ์ํด
GSAP ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํ๊ณ ์ฝ๋๋ฅผ ์์ฑํด๋ณด์.
1. ๋์ ํ๋ฉด

2. ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ค์น
- ํ๋ก์ ํธ ํฐ๋ฏธ๋
npm์ด๋ฉด
npm install gsap
3. ์ฝ๋
- GameView.vue
<img ref="rabbitEl" src="@/assets/rabbit2.png" class="runner runner-rabbit" />
<img ref="turtleEl" src="@/assets/turtle2.png" class="runner runner-turtle" />
const rabbitEl = ref(null)
const turtleEl = ref(null)
const {
countdown,
message,
reset,
running,
start,
winner,
} = useRaceGame({ rabbitEl, turtleEl })
GSAP์ ์ค์ DOM์ ์ง์ด์ ๊ทธ ์คํ์ผ์ ์ค์๊ฐ์ผ๋ก ๋ฐ๊ฟ์ค๋ค. ๊ทธ๋์ GSAP์ ์ฌ์ฉํ๋ ค๋ฉด ์ง์ง DOM์ ๋๊ฒจ์ค์ผํ๋ค. ๊ทธ๋ ๊ธฐ์ ์๋ฐ์คํฌ๋ฆฝํธ๋ก ์กฐ์ข ํ ์ ์๋๋ก GameView.vue์์ ref๋ฅผ ๋ถ์ฌ์ useRaceGame์ผ๋ก ๋๊ฒจ์ค๋ค.
GameView.vue๋ ๊ฒ์ ํ๋ฉด๋ง ๋ณด์ฌ์ฃผ๋ ์ญํ ์ด๊ณ , ์ค์ ์์ง์ด๋ ๋ชจ๋ ๋ก์ง์ useRaceGame.js์์ ์งํํ๋ค. GameView์์ rabbitEl, turtleEl์ useRaceGame์๊ฒ ํ๋ผ๋ฏธํฐ๋ก ๋ณด๋ด๋ฉด, GameView๋ DOM(ํ๋ฉด)๋ง ๋์ฐ๊ณ useRaceGame์ ์ด ์์๋ฅผ ๋ฐ์์ GSAP์ผ๋ก ์์ง์ด๋ ๊ถํ์ ๊ฐ๋๋ค.
- useRaceGame.js (GSAP๊ณผ ๊ด๋ จ๋ ์ฝ๋๋ง)
import { gsap } from 'gsap'
์ค์นํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๋ถ๋ฌ์จ๋ค.
let timeline
์ฌ๋ฌ ์ ๋๋ฉ์ด์ ์ ์๊ฐ ์์๋๋ก ๋ฌถ์ด์ค ํ์๋ผ์ธ ๊ฐ์ฒด๋ฅผ ๋ด์ ๋ณ์์ด๋ค. ์๋ก์ด ๊ฒฝ์ฃผ๊ฐ ์์๋ ๋๋ง๋ค ์ด์ ํ์๋ผ์ธ์ ๋ฒ๋ฆฌ๊ณ ์๋ก์ด ํ์๋ผ์ธ์ ๋ฎ์ด๋์์ผํด์ const(์์)๊ฐ ์๋ let(๋ณ์)์ ์ฌ์ฉํ๋ค.
function clear() {
timeline?.kill()
timeline = null
if (rabbitEl.value) gsap.killTweensOf(rabbitEl.value)
if (turtleEl.value) gsap.killTweensOf(turtleEl.value)
}
onBeforeUnmount(clear)
์๋ก์ด ๊ฒฝ์ฃผ๊ฐ ์์ํ๊ธฐ ์ง์ ํน์ ์ฌ์ฉ์๊ฐ ์ด ๊ฒ์ ํ๋ฉด์ ์์ ๋ ๋๋ ๊ฒฝ์ฐ clear๊ฐ ๋ฐ์ํ๋ค. ์ฌ์ฉ์๊ฐ ์์ ๋ฒํผ์ ๋๋ฅด๋ฉด ์ฝ๋๋ [ start -> reset -> clear ] ์ด๋ ๊ฒ ํ๋ฐ๊ฐ๋ค.
์ด์ ๊ฒ์์์ ํ ๋ผ๋ ๊ฑฐ๋ถ์ด๊ฐ ๊ฒฐ์น์ ์ ๋ค์ด์ ์๋ ์ํ์ผํ ๋ฐ, ์ด๋ ์์ง ์ ๋๋ฉ์ด์ ์ฐ๊บผ๊ธฐ๊ฐ ๋จ์์์ ์ ์๋ค. ํน์ ์ฌ์ฉ์๊ฐ ๋ค๋ก๊ฐ๊ธฐ๋ฅผ ๋๋ฅด๊ฑฐ๋ ๋ค๋ฅธ ๋ฉ๋ด๋ก ์ด๋ํ๋ค๊ณ ๊ฐ์ ํ์. ๊ฒ์ ํ๋ฉด์ ์ฌ๋ผ์ก์ง๋ง ๋ฐฑ๊ทธ๋ผ์ด๋์์๋ ํ ๋ผ๊ฐ ๊ณ์ ๋ฌ๋ฆฌ๊ณ ์์ ์๋ ์๋ค. ๊ทธ๋์ vue๊ฐ ์ด ์ปดํฌ๋ํธ๊ฐ ๊บผ์ง๊ธฐ ์ง์ onBeforeUnmount)์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ ๋ฏธ๋ค์ด์ ๊น์ง ์น ๋ค ์ฃฝ์ด๋๋ก ์ฝ๋๋ฅผ ์์ฑํ๋ค.
function reset() {
clear()
countdown.value = ''
winner.value = ''
status.value = 'ready'
gsap.set([rabbitEl.value, turtleEl.value].filter(Boolean), {
x: 0,
y: 0,
rotate: 0,
scale: 1,
})
}
const COUNTDOWN_STEPS = ['3', '2', '1', '์ถ๋ฐ!']
์ถ๋ฐ์ ์ ์ธํ ํ๋ reset ํจ์์ด๋ค. ๊ฒฝ์ฃผ๊ฐ ์์๋๊ธฐ ์ , gsap.set์ ์ด์ฉํด์ ํ ๋ผ์ ๊ฑฐ๋ถ์ด ์์น, ๊ฐ๋๋ฅผ 0์ด ๋ง์ x:0 y:0์ผ๋ก ๋๋๋ ค ๋๋๋ค. reset์ด ๋๋ฉด, clear ํจ์๊ฐ ์คํ๋๋ค. ๊ทธ๋ฆฌ๊ณ countdown.value์ 3,2,1,์ถ๋ฐ! ๊ฐ์ ๊ธ์ ๋ฐ์ดํฐ๋ฅผ ์์ ํ ์ง์ด๋ค. ๊ฒ์์ ๋ง์ง๋ง์์ ์ฐ์น์ ๋ฉ์์ง๊ฐ ๋จ๋๋ฐ ๊ทธ๊ฒ๋ ์ง์ด๋ค. status๋ ready๋ก ๋ฐ๊พผ๋ค.
function pickWinner() {
const currentSeconds = new Date().getSeconds()
return currentSeconds % 2 === 0 ? 'rabbit' : 'turtle'
}
๋จผ์ ์น์๋ฅผ ๋ฝ๋๋ค. ๋ก์ง์ ์ฒ์ ์ง ๊ฑฐ๋ ๋๊ฐ๋ค. ํ์ฌ ์ด๊ฐ ์ง์๋ฉด rabbit์ ์น๋ฆฌ, ํ์๋ฉด turtle์ ์น๋ฆฌ์ด๋ค.
function run(nextWinner) {
const finishX = () => window.innerWidth * 0.82
timeline = gsap.timeline({
defaults: {
ease: 'power1.inOut',
},
})
๊ฒฐ์น์ ์์น๋ finishX, ํ๋ฉด ์ ์ฒด ๋๋น(window.innerWidth)์์ 82% ์ง์ ์ ๊ฒฐ์น์ ์ผ๋ก ์ค์ ํ๋ค. ํจ์ () => ํํ๋ก ์์ฑํด์ ์ฐฝ์ ํฌ๊ธฐ๊ฐ ๋ณํด๋ ํญ์ ๋์ ์ผ๋ก ๋ง์ถฐ์ง๋๋ก ํ๋๋ฐ, ์ ๋ ์ง ๋ชจ๋ฅด๊ฒ ๋ค. (๋ด๊ฒ ํ๋ฉด์ด ํ๋์ด๋ค!)
gsap.timeline( )์ ์ฌ๋ฌ ์ ๋๋ฉ์ด์ ์ ์ฐ๊ฒฐํด์ ์ฌ์ํ๊ธฐ ์ํด ํ์๋ผ์ธ์ ๋ง๋ค์๋ค. ๊ธฐ๋ณธ ์์ง์์ธ ease ์์ฑ๊ฐ์ธ power1.inOut์ ์ฌ์ฉํ๋ค. ์ด๊ฑด ๋์ค์ gsap ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์์ฑํ๋ฉด์ ๋ ์์ธํ ์ค๋ช ํ๊ฒ ๋ค.
if (nextWinner === 'rabbit') {
timeline
.to(rabbitEl.value, {
x: () => window.innerWidth * 0.42,
y: -10,
rotate: -4,
duration: 2,
ease: 'power2.out',
}, 0)
.to(rabbitEl.value, {
y: 4,
rotate: -8,
duration: 0.65,
ease: 'sine.inOut',
})
.to(rabbitEl.value, {
y: -8,
rotate: 3,
duration: 0.3,
ease: 'power2.out',
})
.to(rabbitEl.value, {
x: finishX,
y: -12,
rotate: 3,
duration: 2.2,
ease: 'power3.out',
onComplete: () => end('rabbit'),
})
.to(turtleEl.value, {
x: () => window.innerWidth * 0.62,
y: 5,
duration: 5.8,
}, 0)
}
ํ ๋ผ๊ฐ ์ด๊ธธ๋ ์ฝ๋์ด๋ค. ์ถ๋ฐํ์๋ง์ ํ๋ฉด์ 42% ์ง์ ๊น์ง ๋ฐ์ด๊ฐ๋ค. ์ด๊ฒ 2์ด ๊ฑธ๋ฆฌ๋๋ก ํ๋ค. ๋๋ฌด ๋ถ๊ทผ๊น์ง ๊ฐ๋ฉด ๋ฉ์ถฐ์ ์ฝ๊ฐ์ ์์ง์์ ์ฃผ์๋ค. ์ด์ง ๋ชธ์ ํ๋๋ ๋์์ธ๋ฐ, ๋ณ ๋ค๋ฅธ ์๋ฏธ๋ ์๊ณ ๋๋ฆ ์์ด๋ณด์ด๊ณ ์ถ์ด์ ์ถ๊ฐํ๋ค. ๊ทธ๋ฆฌ๊ณ ๊ฒฐ์น์ ์ผ๋ก ๋น ๋ฅด๊ฒ ๋ฌ๋ จ๊ฐ๋ค. ์ด๋ power3.out์ ์ผ๋ค. ๋์ฐฉํ๋ฉด onComplete๊ฐ ๋ฐ๋ํด์ end('rabbit') ํจ์๋ฅผ ๋ถ๋ฅด๊ณ ๊ฒฝ์ฃผ๊ฐ ๋๋๋ค.
๊ฑฐ๋ถ์ด๋ ํ ๋ผ์ ๋์์ ์ถ๋ฐํ์ง๋ง 5.8์ด๋์ ํ๋ฉด์ 62% ์ง์ ๊น์ง ๊ฐ๋๋ก ์ค์ ํ๋ค. ๊ฒฐ์น์ ์ ๋ฟ์ง ๋ชปํ๊ณ ๋๋๋ค.
else {
timeline
.to(rabbitEl.value, {
x: () => window.innerWidth * 0.38,
y: -10,
duration: 2,
ease: 'power2.out',
}, 0)
.to(rabbitEl.value, {
rotate: -8,
y: 2,
duration: 1.1,
ease: 'sine.inOut',
yoyo: true,
repeat: 1,
})
.to(rabbitEl.value, {
x: () => window.innerWidth * 0.68,
y: -6,
rotate: 2,
duration: 2.8,
})
.to(turtleEl.value, {
x: finishX,
y: 2,
duration: 5.7,
ease: 'power1.out',
onComplete: () => end('turtle'),
}, 0)
}
}
๊ฑฐ๋ถ์ด๊ฐ ์ด๊ธธ๋ ์ฝ๋์ด๋ค. ํ ๋ผ๊ฐ ํ๋ฉด์ 38% ์ง์ ๊น์ง ๊ฐ๋ค. ์๋ง ์ด ์ง์ ์ด ๋๋ฌด ๋ถ๊ทผ์ผ ๊ฒ์ด๋ค. ์ด ๋ค์ ์ฝ๋์ yoyo: true๊ฐ ์๋๋ฐ, ๋๋ฌด ๋ถ๊ทผ์์ ํ ๋ผ์ ์์ง์์ด ์ญ์ฌ์๋๋๋ก ํ ๊ฒ์ด๋ค. ๊ฒ์์ ์งํํ ๋ ๋ณด๋ฉด ํ ๋ผ๊ฐ ์ด๊ธธ๋ ํ ๋ผ์ ๊ธฐ์ธ๊ธฐ๊ฐ ํ ๋ฒ ์์ง์ธ๋ค. ๊ทธ๋ฐ๋ฐ ๊ฑฐ๋ถ์ด๊ฐ ์ด๊ธธ ๋ ํ ๋ผ์ ์์ง์์ด 2๋ฒ ๋ฐ์ํ๋ค. ์ด๊ฒ yoyo๋ฅผ trueํด์ ๊ทธ๋ ๋ค. repeat์ 1๋ก ์ค์ ํด์ ํ๋ฒ๋ง ์ญ์ฌ์ ๋๋๋ก ํ๋ค. ๊ทธ๋ค์ ํ ๋ผ๋ ๋ค์ ๊ฒฐ์น์ ์ ํฅํด ์์ง์ด์ง๋ง 68%์์ ๋ฉ์ถ๊ฒ ๋ ๊ฒ์ด๋ค.
๊ฑฐ๋ถ์ด๋ 5.7ํฌ ๋์ ํ๊ฒฐ๊ฐ์ ๊ฑธ์์ผ๋ก ๊ฒฐ์น์ ์ ํฅํด ์์ง์ธ๋ค.
4. ์ ์ฒด์ฝ๋
- useRaceGame.js
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { gsap } from 'gsap'
import confetti from 'canvas-confetti'
const COUNTDOWN_STEPS = ['3', '2', '1', '์ถ๋ฐ!']
const delay = (duration) => new Promise((resolve) => window.setTimeout(resolve, duration))
function pickWinner() {
const currentSeconds = new Date().getSeconds()
return currentSeconds % 2 === 0 ? 'rabbit' : 'turtle'
}
export function useRaceGame({ rabbitEl, turtleEl }) {
const countdown = ref('')
const winner = ref('')
const status = ref('ready')
let timeline
const running = computed(() => status.value === 'countdown' || status.value === 'racing')
const message = computed(() => {
if (winner.value === 'rabbit') return 'ํ ๋ผ ์น๋ฆฌ!'
if (winner.value === 'turtle') return '๊ฑฐ๋ถ์ด ์น๋ฆฌ!'
if (status.value === 'countdown') return '์ ์ ํ ์ถ๋ฐํฉ๋๋ค'
if (status.value === 'racing') return '๊ฒฝ์ฃผ ์ค์
๋๋ค'
return '์ค๋น ์๋ฃ'
})
function clear() {
timeline?.kill()
timeline = null
if (rabbitEl.value) gsap.killTweensOf(rabbitEl.value)
if (turtleEl.value) gsap.killTweensOf(turtleEl.value)
}
function reset() {
clear()
countdown.value = ''
winner.value = ''
status.value = 'ready'
gsap.set([rabbitEl.value, turtleEl.value].filter(Boolean), {
x: 0,
y: 0,
rotate: 0,
scale: 1,
})
}
async function count() {
status.value = 'countdown'
for (const step of COUNTDOWN_STEPS) {
countdown.value = step
await delay(step === '์ถ๋ฐ!' ? 50 : 650)
}
countdown.value = ''
}
function celebrate(nextWinner) {
const origin = nextWinner === 'rabbit'
? { x: 0.88, y: 0.25 }
: { x: 0.88, y: 0.72 }
confetti({
particleCount: 110,
spread: 70,
startVelocity: 36,
origin,
colors: ['#d692c8', '#8caa97', '#f8d0e5', '#fbfbb4', '#ffffff'],
})
}
function end(nextWinner) {
winner.value = nextWinner
status.value = 'finished'
celebrate(nextWinner)
}
function run(nextWinner) {
const finishX = () => window.innerWidth * 0.82
timeline = gsap.timeline({
defaults: {
ease: 'power1.inOut',
},
})
if (nextWinner === 'rabbit') {
timeline
.to(rabbitEl.value, {
x: () => window.innerWidth * 0.42,
y: -10,
rotate: -4,
duration: 2,
ease: 'power2.out',
}, 0)
.to(rabbitEl.value, {
y: 4,
rotate: -8,
duration: 0.65,
ease: 'sine.inOut',
})
.to(rabbitEl.value, {
y: -8,
rotate: 3,
duration: 0.3,
ease: 'power2.out',
})
.to(rabbitEl.value, {
x: finishX,
y: -12,
rotate: 3,
duration: 2.2,
ease: 'power3.out',
onComplete: () => end('rabbit'),
})
.to(turtleEl.value, {
x: () => window.innerWidth * 0.62,
y: 5,
duration: 5.8,
}, 0)
} else {
timeline
.to(rabbitEl.value, {
x: () => window.innerWidth * 0.38,
y: -10,
duration: 2,
ease: 'power2.out',
}, 0)
.to(rabbitEl.value, {
rotate: -8,
y: 2,
duration: 1.1,
ease: 'sine.inOut',
yoyo: true,
repeat: 1,
})
.to(rabbitEl.value, {
x: () => window.innerWidth * 0.68,
y: -6,
rotate: 2,
duration: 2.8,
})
.to(turtleEl.value, {
x: finishX,
y: 2,
duration: 5.7,
ease: 'power1.out',
onComplete: () => end('turtle'),
}, 0)
}
}
async function start() {
if (running.value) return
reset()
await nextTick()
await count()
status.value = 'racing'
run(pickWinner())
}
onBeforeUnmount(clear)
return {
countdown,
message,
reset,
running,
start,
status,
winner,
}
}