캔버스를 사용하여 오징어 게임의 달고나 만들기

나는 한동안 프론트엔드 웹 개발자로 일했지만 HTML5의 캔버스 요소를 1-2번만 사용했고 여가 시간에만 사용했습니다. Wes Bos은 초급 JavaScript 과정에서 캔버스를 사용한 Etch-a-Sketch 게임을 가지고 있습니다. 저처럼 이전에 캔버스를 사용한 적이 없다면 캔버스로 할 수 있는 멋진 일에 대한 좋은 소개입니다.

Netflix에서 Squid Game을 본 후 브라우저에서 해당 게임을 다시 만들 수 있는지에 대해 생각하기 시작했습니다.

Slight Spoilers ahead for Squid Game





View on Github

분명한 선택은 내가 캔버스에 대해 기억하고 자유형으로 그릴 수 있다는 점에서 사용자가 모양을 그릴 수 있다는 점을 기반으로 한 달고나였습니다. 그러나 사용자가 모양을 그릴 필요가 있을 뿐만 아니라 모양을 미리 로드해야 하고 사용자가 일치시키기 위해 추적해야 하고 맨 마지막에 둘을 비교하고 결정하는 방법이 필요했습니다. 그들이 가까웠는지 여부.

이 시점에서 나는 어디서부터 시작해야 할지 몰랐지만 "캔버스에서 게임 추적"을 빠르게 검색한 결과 Letterpaint 이라는 간단한 예제가 나타났습니다. 가능한.

이 프로젝트는 캔버스 초보자에게 최고의 아이디어가 아니었습니다. 일주일에 한 번 Codepen이나 Dev.to 블로그 게시물을 만드는 것이 목표였지만 이 프로젝트를 시작하고 나면 모든 것이 중단되었습니다. 나는 우산을 그리는 방법을 알아내려고 주말 내내 이틀을 보냈습니다. 어떤 우산도 아닙니다. 정확성을 위해 쇼에서 나온 우산이어야 했습니다.

재미있는 아이디어로 시작한 것이 좌절되고 몇 번이나 포기할까 생각했습니다. 이것이 주말에 코딩 시간을 사용하는 가장 좋은 방법인지 궁금했습니다. 그러나 결국 호기심이 이겼고 코드가 제대로 작동하도록 했습니다. 코드가 가장 예쁘지 않고 리팩토링이 필요합니다. 그러나 작동하게 하는 데 큰 성취감을 느꼈습니다. 그리고 어떤 면에서는 정직하게 느껴졌습니다. 코딩은 어렵고 항상 "하루에 HTML을 배울"수는 없습니다. 그래서 저는 이 게임이 어떻게 작동하는지 뿐만 아니라 이 게임을 끝내기 위해 겪었던 어려움과 문제 해결에 대해 설명할 것입니다.
  • Set up Canvas

  • Draw the Shapes
  • The Triangle
  • The Circle
  • The Star
  • The Umbrella

  • Set Up User Paint Functionality
  • Compare the User Input with the Shape
  • Determine Win State
  • Reset Everything
  • Resize Everything
  • Testing on Mobile
  • Conclusion

  • 캔버스 설정

    This is standard code whenever you use canvas. You'll want to set the drawing context, the width and height, and also the line style.

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    /* Set up the size and line styles of the canvas */
    function setupCanvas() {
       canvas.height = 370;
       canvas.width = 370;
       canvas.style.width = `${canvas.width}px`;
       canvas.style.height = `${canvas.height}px`;
       ctx.lineWidth = 12;
       ctx.lineCap = 'round';
    }
    

    도형 그리기

    This is where being a novice to canvas became a huge obstacle. I had never tried to draw any shapes either using SVGs or canvas, so trying to brute-force my way through all of these was quite the challenge.

    삼각형

    This was the first shape I attempted, and the main struggle I had here was actually due more to geometry than coding. If you're trying to draw a polygon this is very straightforward. You set a starting point consisting of x and y coordinates, then tell the canvas to draw a line to another set of coordinates, and so on, for a total of 3 separate coordinates to make a triangle.

    I initially tried to make this an exact equilateral triangle, but rather than try to look up the geometry formulas I decided to just manually test the coordinates and settled on what looked "right" without worrying about making it perfect.

    /* Triangle shape */
    function drawTriangle() {
       ctx.strokeStyle = 'rgb(66, 10, 0)';
       ctx.beginPath();
       ctx.moveTo(185, 85);
       ctx.lineTo(285, 260);
       ctx.lineTo(85, 260);
       ctx.closePath();
       ctx.stroke();
    }
    

    동호회

    Circles are actually pretty easy to draw. Using the built-in arc() method, you can just specify the center of the circle and then add another parameter for the radius. The final two parameters will always be the same if you're making a complete circle.

    function drawCircle() {
       ctx.strokeStyle = 'rgb(66, 10, 0)';
       ctx.beginPath();
       ctx.arc(185, 185, 100, 0 * Math.PI, 2 * Math.PI);
       ctx.closePath();
       ctx.stroke();
    }
    

    더 스타

    (저는 오픈 소스를 좋아합니다).

    function drawStar() {
       ctx.strokeStyle = 'rgb(66, 10, 0)';
    
       let rot = Math.PI / 2 * 3;
       let x = 185;
       let y = 185;
       let cx = 185;
       let cy = 185;
       const spikes = 5;
       const outerRadius = 120;
       const innerRadius = 60;
       const step = Math.PI / 5;
    
       ctx.strokeSyle = "#000";
       ctx.beginPath();
       ctx.moveTo(cx, cy - outerRadius)
       for (i = 0; i < spikes; i++) {
           x = cx + Math.cos(rot) * outerRadius;
           y = cy + Math.sin(rot) * outerRadius;
           ctx.lineTo(x, y)
           rot += step
    
           x = cx + Math.cos(rot) * innerRadius;
           y = cy + Math.sin(rot) * innerRadius;
           ctx.lineTo(x, y)
           rot += step
       }
       ctx.lineTo(cx, cy - outerRadius)
       ctx.closePath();
       ctx.stroke();
    }
    


    나는 이것을 수동 좌표로 설정하여 삼각형을 그리면서 이것을 간단히 그리려고했지만 포기하고 누군가가 있음을 발견했습니다. Umbrella는 동적 기능을 코딩했습니다.

    오기훈, 너의 고통이 느껴진다. 나는 이것에 대해 여러 가지 방법으로 갔다. 우산을 수동으로 그린 ​​다음 SVG 이미지로 캔버스에 가져오기 위해 오픈 소스 벡터 소프트웨어를 다운로드했지만 곡선을 제대로 그리는 방법을 알 수 없었고 이 게임에서 하나의 모양을 그리는 프로그램을 배우는 것은 과한 것 같았습니다. . 삼각형처럼 수동으로 그리기 위해 여러 번 시도했지만 lineTo()는 곡선이 아닌 다각형에 대해 작동합니다. 그런 다음 곡선을 그리는 방법인 arc() 방법이 이미 존재한다는 깨달음을 얻었습니다. 우산은 크기가 다른 여러 개의 곡선과 직선의 집합이 아니었습니까? 둘 다 이미 했습니까? 나는 이것을 알아내기 위해 등을 두드렸다. ...안타깝게도 현실적으로 쉽지는 않았습니다. 첫 번째 호 - 메인 전체 파라솔은 충분히 쉬웠고, 완전한 원 대신 반원이 되도록 arc() 메소드를 약간 수정한 다음 기본 방향을 변경해야 했습니다. 그러나 추가 호를 추가하기 시작하면 모든 후속 호가 직선 수평선으로 중간에 호 아래의 경로를 닫기 시작했습니다. ctx.beginPath(); // 우산 파라솔 ctx.arc(200, 180, 120, 0*Math.PI, 1 * Math.PI, true); // 우산 곡선 ctx.moveTo(105, 180); ctx.arc(105, 180, 25, 0*Math.PI, 1 * Math.PI, true); 나는 이것을 알아낼 수 없었다. 첫 번째 파라솔 호를 제거하면 이 수평선이 두 번째 호에서 사라졌지만 다른 호를 추가하면 그 문제가 다시 발생합니다. 나는 beginPath()와 stroke()로 시행착오의 과정을 거쳤고 마침내 모든 개별 호에 대해 별도의 하위 기능을 만들어 작동하게 되었습니다. /* 개별 호 그리기 */ function drawArc(x, y, 반경, 시작, 끝, counterClockwise = true) { ctx.beginPath(); ctx.arc(x, y, 반지름, 시작 * Math.PI, 끝 * Math.PI, 시계 반대 방향); ctx.stroke(); } 이것이 원래 기능과 반대로 작동한 이유는 무엇입니까? 솔직히 나는 아무 생각이 없다. moveTo()로 인해 선이 그려졌을 수 있습니다. 이 시점에서 나는 그대로 두고 수정하지 말라고 스스로에게 말했고 그렇지 않으면 그것을 다시 깨뜨릴 위험을 감수했습니다. 변경 사항을 즉시 Github에 커밋했고 작동하게 된 것에 대해 엄청난 기쁨을 느꼈습니다. 우산을 그리는 방법을 알아내는 놀라운 기쁨. 가끔은 사소한 일입니다. /* 우산 모양 */ 함수 drawUmbrella() { ctx.strokeStyle = 'rgb(66, 10, 0)'; /* 개별 호 그리기 */ drawArc(185, 165, 120, 0, 1); // 큰 파라솔 drawArc(93, 165, 26, 0, 1); drawArc(146, 165, 26, 0, 1); drawArc(228, 165, 26, 0, 1); drawArc(279, 165, 26, 0, 1); /* 핸들 그리기 */ ctx.moveTo(172, 165); ctx.lineTo(172, 285); ctx.stroke(); drawArc(222, 285, 50, 0, 1, 거짓); drawArc(256, 285, 16, 0, 1); drawArc(221, 286, 19, 0, 1, 거짓); ctx.moveTo(202, 285); ctx.lineTo(202, 169); ctx.stroke(); } 사용자 페인트 기능 설정

    사용자가 캔버스에 무엇이든 칠하게 하려는 경우보다 이를 더 복잡하게 만드는 몇 가지 사항이 있습니다. 그림이 캔버스의 기본 동작처럼 얼룩지지 않고 연속선이 되도록 하려면 사용자의 이전 x 및 y 좌표에 연결해야 합니다. 함수 페인트(x, y) { ctx.strokeStyle = 'rgb(247, 226, 135)'; ctx.beginPath(); /* 연속선 그리기 */ if (이전X > 0 && 이전Y > 0) { ctx.moveTo(이전X, 이전Y); } ctx.lineTo(x, y); ctx.stroke(); ctx.closePath(); 이전X = x; 이전 Y = y; } 여기에 자세히 설명되지 않은 다른 기능: 모양 자르기를 더 잘 제어하기 위해 사용자는 마우스를 누르고 있는 동안에만 그려야 하며 커서를 처음부터 그리기로 이동할 때 자동으로 그리지 않아야 합니다. 또한 이를 더 어렵게 만들기 위해 사용자는 한 번의 연속 동작만 시도할 수 있습니다. 사용자가 마우스를 놓으면 최종 게임이 시작됩니다. 따라서 하나의 연속 동작으로 추적을 완료해야 합니다. 색상 기반 모양과 사용자 입력 비교

    이제 사탕 모양이 생겼고 사용자는 모양 위에 그림을 그릴 수 있지만 사용자가 모양을 정확하게 추적했는지 어떻게 확인할 수 있습니까? 가장 먼저 생각한 것은 도면에 있는 각 픽셀의 좌표를 알아낸 다음 사용자가 추적한 모양의 좌표와 비교하는 것이었습니다. 이것이 레터페인트 게임의 논리가 다시 등장하여 일을 훨씬 쉽게 만든 곳입니다. 모양은 모두 같은 색상을 사용하고 사용자 페인팅은 다른 색상을 사용합니다. 그렇다면 좌표를 비교하는 대신 각 색상의 픽셀 수를 서로 비교하는 것은 어떨까요? 사용자가 모양을 완벽하게 추적하는 데 성공했다면 칠해진 픽셀의 수는 모양 픽셀의 수와 같으므로 1이 됩니다. 사용자가 모양의 절반만 완벽하게 칠하면 비율은 50%가 됩니다. 이를 위해 픽셀 데이터를 포함하는 객체를 반환하는 getImageData 메서드를 사용하여 픽셀 데이터를 가져오는 함수가 있습니다. 함수 getPixelColor(x, y) { 상수 픽셀 = ctx.getImageData(0, 0, 캔버스.너비, 캔버스.높이); let index = ((y * (pixels.width * 4)) + (x * 4)); 반품 { r:픽셀.데이터[인덱스], g:픽셀.데이터[인덱스 + 1], b:픽셀.데이터[인덱스 + 2], a:픽셀.데이터[인덱스 + 3] }; } 따라서 모양을 그리는 모든 함수에 대해 픽셀 수를 가져오기 위해 함수를 호출해야 합니다. 함수 drawCircle() { /* 원 코드 그리기... */ /* 모양의 픽셀 가져오기 */ 픽셀 모양 = getPixelAmount(66, 10, 0); } 하지만 잠시만요. 사용자가 실제로 추적하지 않고도 똑같은 모양을 그릴 수 있다는 뜻인가요? 아니면 사용자가 그림과 같은 양의 픽셀 덩어리를 구불구불하게 그릴 수 있습니까? 예, 사용자가 모양을 너무 많이 벗어나지 않도록 페인트 기능에 대한 검사를 실제로 추가해야 하는 것을 방지하기 위해: 색상 = getPixelColor(x, y); if (color.r === 0 && color.g === 0 && color.b === 0) { score.textContent = `FAILURE - 모양이 깨졌습니다`; breakShape = 참; } 다시, 우리는 픽셀을 확인하고 있으며 r, g, b가 0이면(사용자가 캔버스에 아무 것도 칠하지 않은 상태에서 그림을 그리는 중) 자동으로 게임에 실패한 것입니다. 쇼처럼 즉석 게임 오버. 여기에는 내가 잘 알아낼 수 없는 약간의 버그가 있습니다. 나는 그릴 때 r, g, b 값을 콘솔에 로그아웃했는데 드물게 r이 66(모양의 색상)과 같은 대신 65 또는 기타 매우 작은 차이를 반환했습니다. 따라서 각 색상의 실제 픽셀 양은 100% 정확하지 않을 수 있습니다. 승리 상태 결정

    우리는 그림과 사용자 그림 사이의 픽셀을 비교하고 있으며 사용자가 이미 모양을 깨뜨리지 않았는지 여부만 확인하고 일정 비율을 획득하면 승리합니다. 함수 평가픽셀() { if (!brokeShape) { 상수 픽셀 추적 = getPixelAmount(247, 226, 135); let pixelDifference = pixelTrace / pixelShape; /* 사용자가 마지막 50%에서 득점했습니다 */ if (pixelDifference >= 0.75 && pixelDifference <= 1) { score.textContent = `SUCCESS - ${Math.round(pixelDifference * 100)}%`를 기록했습니다. } 또 다른 { score.textContent = `FAILURE - ${Math.round(pixelDifference * 100)}%`를 잘라냈습니다. } } } 모두 재설정

    104544410410104446792310 104666666666666666666666666666666666666666666666666666667101044410104446792310 기본적으로 우리는 모든 마우스 이벤트 핸들러를 해당 터치에 매핑해야 하며 터치스크린일 때 화면이 스크롤되지 않도록 해야 합니다. 즉, 캔버스 아래에서 초기 모양 선택 팝업으로 지침을 이동해야 했으며(모바일에서는 스크롤이 필요하지 않음), 모바일에서는 너무 가늘게 느껴지기 때문에 캔버스 선 너비를 12에서 15로 늘려야 했습니다. . 또한 "모양 깨기"는 의도치 않게 모바일에서 훨씬 더 관대합니다. 즉, 사용자가 모양 외부를 훨씬 더 많이 칠할 수 있으므로 100% 이상의 점수를 받은 사용자도 실패하도록 유효성 검사를 추가해야 합니다. . 이 시점에서 나는 다른 사람들이 그것을 가지고 놀게 할 때라고 느꼈습니다.

    모든 것 크기 조정 여기에는 많은 작은 기능이 있습니다. 기본적으로 우리는 게임을 다시 시작할 때 모든 것을 지우고 싶습니다: 모양 지우기, 이전 x 및 y 좌표 지우기, 결과 지우기, 저장된 픽셀 데이터 지우기, 게임 상태 재설정하기. 함수 clearCanvas() { ctx.clearRect(0, 0, 캔버스.너비, 캔버스.높이); gameStart.classList.remove('숨겨진'); 마우스다운 = 거짓; 시작 회전 = 거짓; 부러진 모양 = 거짓; 점수.텍스트 내용 = ''; 이전X = ''; 이전 = ''; 픽셀 모양 = 0; }

    좋은 웹페이지 즐겨찾기