javascript canvas를 사용해서 룰렛 만들기 위한 과정을 단계별로 정리해보았습니다. 이 글에서는 제가 룰렛을 어떻게 만들었는지 설명합니다.
룰렛 만들기 과정
- 배경 원 그리기: 룰렛의 기본 틀을 형성하는 배경 원을 그립니다.
- 룰렛 화살표 그리기: 룰렛이 멈췄을 때 당첨 아이템을 가리킬 화살표를 그립니다.
- 룰렛 아이템 그리기: 룰렛에 표시될 각종 아이템을 그립니다.
- 룰렛 회전하기: 룰렛을 회전시킵니다.
- 이징 함수: 회전의 자연스러운 속도 변화를 위해 이징 함수를 적용합니다.
배경 원 그리기
원 그리기는 Canvas 2D API에서 제공하는 arc함수를 사용해서 그립니다. arc함수는 다음과 같이 사용합니다.
arc(x, y, radius, startAngle, endAngle, counterclockwise);
x: 수평 좌표
y: 수직 좌표
radius: 반지름
startAngle: 시작 radian 값
endAngle: 끝 radian 값
radian은 각도의 단위 중 하나 입니다. 원은 2π 라디안이고, 360도에 해당합니다.
먼저 반지름이 240px 원을 그려 보겠습니다. 캔버스의 크기는 가로 세로 500px로 설정 했습니다. x와 y에 각각 250 값을 주고, 반지름은 239만큼 주었습니다. 239로 설정한 이유는 stroke를 1만큼 주었기 때문입니다. 시작 각은 0, 끝 각은 2π 만큼 설정하면 됩니다.
<canvas id="canvas"></canvas>
<button id="spin">spin</button>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
canvas.width = 500;
canvas.height = 500;
function drawCircle() {
ctx.arc(250, 250, 240 - 1, 0, Math.PI * 2);
ctx.fillStyle = "#63FF84";
ctx.fill();
ctx.lineWidth = 1;
ctx.stroke();
}
drawCircle();
룰렛 화살표 그리기
캔버스의 12시 지점에 룰렛의 아이템을 가리키는 가로 세로가 14px인 삼각형을 만들겠습니다.
function drawPointer() {
ctx.beginPath(); // 새 경로를 만듬
// 새 경로를 만들지 않으면 이전 그린 원에 대해서 drawPointer 함수에 있는 style 적용 함수들이 이전에 그린 것들에도 적용이 됨
ctx.moveTo(250, 20); // 시작 좌표를 설정
ctx.lineTo(243, 6);
ctx.lineTo(257, 6);
ctx.closePath(); // 열려있는 경로를 연결
ctx.fillStyle = "#fff";
ctx.fill();
ctx.lineWidth = 1;
ctx.stroke();
}
drawPointer();
moveTo를 사용해서 선의 시작 좌표를 설정합니다. x250 y20으로 설정했고 lineTo를 사용해서 x250 y20에서 양쪽으로 좌쪽으로 x는 7px인 243, y는 20에서 14를 빼니 6입니다. 우측으로 x는 257 y는 6을 설정하고 closePath를 사용해서 경로를 연결하면 삼각형이 완성됩니다.
룰렛 아이템 그리기
위에서 원을 그릴 때 arc함수에 시작 각도에 0, 끝 각도에 2π 의 값을 주었더니 원이 그려졌습니다. 아이템은 아이템이 1개이면 아이템의 텍스트만 표시하면 됩니다. 아이템이 2개이면 2π/2로 설정해서 원을 각 아이템이 각각 절반 만큼 차지하면 됩니다.
결론은 2π를 아이템의 개수 만큼 나누면 각 아이템을 원에 그려줄 수 있습니다.
테스트 데이터를 준비했습니다.
const testData = [
{
id: 0,
text: '김치찌개',
background: '#ab6c66',
},
{
id: 1,
text: '부대찌개',
background: '#86bc85',
},
{
id: 2,
text: '된장찌개',
background: '#a5adce',
},
];
let segments = [...testData];
function drawSegments(angle) {
const addAngle = (Math.PI * 2) / segments.length;
ctx.clearRect(0, 0, 500, 500); // clearRect로 배경을 지움, 지우는 이유는 뒷 배경이 살짝씩 보이기 때문에 지져분함 자세하게 보면 보더가 두꺼운게 보임
segments.forEach((segment, index) => {
ctx.beginPath();
segments.length !== 1 && ctx.moveTo(250, 250); // moveTo 반드시 있어야함 1개 일때는 moveTo가 없어도됨
ctx.arc(250, 250, 240 - 1, angle, angle + addAngle);
ctx.closePath(); // 경로를 닫지 않으면 선의 두께가 다르게 보임, 이유는 아이템의 경로들의 처음 지점과 마지막 지점이 연결되지 않기 때문
ctx.fillStyle = segment.background;
ctx.fill();
ctx.lineWidth = 1; // 선 두께 설정
ctx.stroke(); // 경로 외곽선 그리기
ctx.save(); // 현재 상태 저장
ctx.translate(250, 250); // 캔버스의 좌표를 중심으로 이동
segments.length !== 1 && ctx.rotate(angle + addAngle / 2); // 각 세그먼트의 중심 각도로 회전
ctx.textAlign = 'center'; // 텍스트 정렬 설정
ctx.font = 'bold 28px sans-serif'; // 텍스트 스타일 설정
ctx.fillStyle = 'black'; // 텍스트 색상 설정
ctx.fillText(segment.text, 240 / 2, 0); // 텍스트 그리기
ctx.restore(); // 이전 상태 복원
angle += addAngle; // 다음 세그먼트의 시작 각도 업데이트
});
drawPointer();
}
drawCircle();
drawPointer();
drawSegments(0);
closePath는 경로의 시작 지점과 마지막 지점을 연결하여 경로를 완성 시킵니다. 따라서 closePath를 사용하지 않으면 선의 두께가 다르게 보입니다.
text를 그리기 전 반드시 save를 사용해야 합니다. text를 그릴 때 translate를 사용하기 때문이고 translate는 캔버스의 전체 좌표를 이동 시킵니다. 따라서 save와 restore를 사용해서 이전 상태로 돌아가는 것입니다.
save는 현재의 그래픽 상태(변환 행렬, 클리핑 영역, 스타일 속성 등)를 스택에 저장합니다. restore는 스택에서 마지막으로 저장한 상태를 가져와 현재의 그래픽 상태를 복원합니다.
moveTo와 translate차이
moveTo
- 경로의 시작점을 이동시킵니다.
- 캔버스의 좌표계에는 영향을 주지 않습니다.
- 현재 경로 내에서만 사용됩니다.
translate
- 캔버스의 전체 좌표계를 이동시킵니다.
- 이후 그려지는 모든 도형과 텍스트의 위치에 영향을 미칩니다.
- 좌표계의 변환을 통해 도형의 위치를 조정합니다.
룰렛 회전하기
let spinning = false; // 회전 상태
let selectedSegment = null // 랜덤 값을 저장
let rotationCount = 20; // 기본 회전수
let totalRotation = 0 // 계산된 전체 회전수에 대한 라디안 값
let startTimestamp = null // animate 함수가 실행된 시간
let duration = 5000 // 회전 시간
function spin(){
if (spinning) return;
selectedSegment = Math.floor(Math.random() * segments.length); // 랜덤 아이템
console.log(segments[selectedSegment])
// 최종 회전수를 구하기 위해 계산해야 되는 것들이 있습니다.
// 먼저 하나의 아이템이 차지하는 라디안 값을 구합니다.
const angle = (Math.PI * 2) / segments.length;
// 화살표의 위치는 원의 12시 위치합니다.
// 해당 위치는 12시는 0도가 3시이고 따라서 270도에 해당합니다.
// 270도는 Math.PI * 1.5 입니다.
// 이 값에서 angle * 선택된 segement를 빼면 항상 선택된 아이템의 시작각은 270도에 도착합니다.
const correctionAngle = Math.PI * 1.5 - angle * selectedSegment;
// correctionAngle이 선택된 값을 화살표로 위치하게 했다면 randomAngle의 역할은
// 선택된 아이템이 자연스럽게 보이게 하기 위해서 사용했습니다.
// Math.PI * 2 / segments.length 는 하나의 아이템이 차지하는 라디안 값 입니다.
// 0 ~ Math.PI * 2 / segments.length 해당하는 값을 랜덤으로 주어서
// 0도에서 멈추는 것이 아니라 선택된 아이템이 아이템의 범위내에서 자연스럽게
// 멈춘것처럼 보이게 만듭니다.
const randomAngle = Math.random() * (0 - -angle) + -angle
// 전체 라디안 값입니다.
totalRotation = Math.PI * 2 * rotationCount + correctionAngle + randomAngle
console.log(totalRotation, correctionAngle, randomAngle)
requestAnimationFrame(animate)
}
function animate(timestamp) {
if(!spinning) {
spinning = true
startTimestamp = timestamp
}
let elapsed = timestamp - startTimestamp;
let progress = Math.min(elapsed / duration, 1);
let angle = totalRotation * progress;
drawSegments(angle);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
spinning = false;
}
}
correctionAngle: 아이템의 시작각이 270도에 위치하게 합니다. randomAngle: 아이템이 가지고 있는 범위가 있는데 예를 들어서 아이템이 3개라면 각각 0 ~ 120, 120 ~ 240, 240 ~ 360 가지고 있습니다. 룰렛을 회전시키고 멈췄을 때 자연스럽게 보이게 하기 위해서 가지고 있는 범위에서 값이 랜덤으로 나오게 합니다. totalRotation: 전체 회전수
이징 함수 추가하기
animate 함수의 progress 부분을 수정하면 됩니다. 저는 easings.net 에서 제공해주는 코드를 사용했습니다.
// https://easings.net/#easeInOutQuad
function easeInOutQuad(x) {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
function animate(timestamp) {
if(!spinning) {
spinning = true
startTimestamp = timestamp
}
let elapsed = timestamp - startTimestamp;
let progress = easeInOutQuad(Math.min(elapsed / duration, 1));
let angle = totalRotation * progress;
drawSegments(angle);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
spinning = false;
}
}
timestamp는 requestAnimationFrame에 의해 전달되는 밀리초 단위의 현재 시간입니다. 애니메이션이 시작되면, 처음 전달된 timestamp 값을 startTimestamp 변수에 저장합니다. 이후 프레임마다 전달되는 timestamp 값에서 startTimestamp 값을 빼면 경과된 시간인 elapsed를 계산할 수 있습니다.
progress는 애니메이션의 진행 상태를 나타내며, elapsed를 duration으로 나누어 계산됩니다. elapsed 값이 duration 값보다 크면 progress는 1이 되어 애니메이션이 종료됩니다.
progress 값이 1이 되면 animate 함수는 종료됩니다.
CODEPEN
See the Pen roulette by CodingCitron (@codingcitron) on CodePen.
내가 만든 룰렛 사이트: https://qzz.app/ 오류가 있으면 댓글 남겨주세요.
잘 보고 가요! 저도 Canvas를 이용해서 재미있는 것들 좀 만들어보고 싶네요.
앗 ㅋㅋ 좋게 봐주셔서 감사합니다.