[프론트엔드 프로젝트] 바닐라 JS로 ‘Swipe Brick Breaker(스와이프 벽돌깨기)’ 게임 만들기: 코드 리뷰 및 알고리즘 해설

안녕하세요! studyformyself입니다. 최근 블로그에 바닐라 자바스크립트(Vanilla JS)와 HTML5 Canvas만을 활용하여 직접 개발한 Swipe Brick Breaker(스와이프 벽돌깨기) 게임을 퍼블리싱했습니다.

프론트엔드 개발을 공부하시다 보면 DOM 조작을 넘어 “화면 위에서 객체들이 어떻게 물리적으로 상호작용하게 만들까?” 하는 궁금증이 생기기 마련입니다. 오늘은 이 게임이 HTML/JS 단에서 어떻게 구현되었는지, 핵심 알고리즘을 블록 단위로 쪼개어 리뷰해 보겠습니다. 캔버스 API와 2D 게임 로직에 관심 있는 분들께 도움이 되길 바랍니다!

1. HTML 및 Canvas 뼈대 설정

<div id="swipe-game-container" style="text-align: center; margin: 20px auto;">
  <canvas id="gameCanvas" width="400" height="500" 
          style="background-color: #000000; touch-action: none; max-width: 100%;">
  </canvas>
</div>

게임의 렌더링을 담당할 도화지인 <canvas> 태그를 설정하는 단계입니다. 가장 중요한 포인트는 CSS 속성인 touch-action: none; 입니다. 모바일 웹 브라우저에서 사용자가 화면을 터치하고 드래그(스와이프)할 때, 브라우저의 기본 동작인 ‘화면 스크롤’이 발생하는 것을 원천적으로 차단해 줍니다. 모바일 친화적인 웹 게임을 만들 때 반드시 들어가야 하는 속성입니다.

2. 상태 관리 및 그리드 시스템

// 상태 관리 및 전역 변수
let gameState = 'idle'; // 상태: idle(대기), shooting(발사 중), gameover(종료)
let turn = 1;
let score = 1; 
let ballsCount = 1; // 보유한 총 공의 개수
let balls = [];     // 현재 화면에 날아다니는 공 객체 배열

// 9행 6열 그리드 시스템 (배열)
const brickRowCount = 9;
const brickColumnCount = 6;
let bricks = [];

function initBricks() {
  for (let c = 0; c < brickColumnCount; c++) {
    bricks[c] = [];
    for (let r = 0; r < brickRowCount; r++) {
      bricks[c][r] = { type: 'empty', hp: 0 }; 
    }
  }
}

게임 로직을 제어하기 위해 상태 머신(State Machine) 패턴을 가볍게 차용했습니다. gameState가 ‘idle’일 때만 조준이 가능하고, ‘shooting’일 때는 물리 엔진만 돌아가도록 분기 처리를 하기 위함입니다. 벽돌이 배치되는 공간은 9행 6열의 2차원 배열(bricks[c][r])로 관리합니다. 각 칸은 비어있거나(empty), 벽돌이거나(brick), 공 추가 아이템(item)이라는 타입과 체력(hp) 정보를 객체 형태로 가집니다. 턴이 끝날 때마다 이 배열의 데이터를 한 칸씩 밑으로 복사하여(Shift) 블록이 내려오는 것을 구현합니다.

3. 크로스 플랫폼 이벤트 핸들링 (PC & 모바일)

// 터치 및 마우스 좌표 통합 추출 함수
function getPos(e) {
  if (e.changedTouches && e.changedTouches.length !== 0) {
    return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
  }
  return { x: e.clientX, y: e.clientY };
}

function handleEnd(e) {
  if (!isAiming || gameState !== 'idle') return;
  isAiming = false;

  let dx = currentX - startX;
  let dy = currentY - startY;
  let distance = Math.sqrt(dx * dx + dy * dy);
  
  if (distance < 10) return; // 단순 터치는 무시

  // 조준 벡터 정규화(Normalize) 및 발사 속도 적용
  let dirX = dx / distance;
  let dirY = dy / distance;
  
  shootDx = dirX * ballSpeed;
  shootDy = dirY * ballSpeed;
  
  gameState = 'shooting'; // 상태 전환
}

canvas.addEventListener('mousedown', handleStart);
canvas.addEventListener('touchstart', handleStart, { passive: false });
// ... (move, end 이벤트도 동일하게 바인딩)

PC(마우스)와 모바일(터치) 환경을 모두 지원하기 위해 getPos() 함수로 좌표값을 추상화했습니다. 마우스를 드래그하고 손을 떼는 순간(handleEnd), 시작점과 끝점의 차이(dx, dy)를 통해 피타고라스의 정리(Math.sqrt)로 거리를 구합니다. 이 거리가 너무 짧으면 오터치로 간주하여 무시합니다. 거리가 충분하다면 dx, dy를 거리로 나누어 크기가 1인 방향 벡터(단위 벡터, Normalize)를 구한 뒤, 여기에 공의 기본 속도(ballSpeed)를 곱해 최종 이동 속력(shootDx, shootDy)을 세팅합니다.

4. 고도화된 타격 예측 알고리즘 (Raycasting)

// 공의 예상 타격 지점 계산 알고리즘
function getRayCollision(sX, sY, dX, dY) {
  let minT = 999999; // 충돌까지 걸리는 최소 시간(거리)
  let radius = 8;
  
  // 1. 벽면 충돌 검사
  if (dX !== 0) {
    let tLeft = (radius - sX) / dX; // 왼쪽 벽 충돌 계산
    if (tLeft > 0 && tLeft < minT) minT = tLeft;
    // ... 오른쪽 벽, 천장 계산 생략
  }

  // 2. 블록 충돌 검사 (AABB 알고리즘 응용)
  for (let c = 0; c < brickColumnCount; c++) {
    for (let r = 0; r < brickRowCount; r++) {
      if (bricks[c][r].type === 'brick' && bricks[c][r].hp > 0) {
        // 벽돌의 Bounding Box 영역 (반지름만큼 확장)
        let minX = brickX - radius; 
        let maxX = brickX + brickWidth + radius;
        // ... (Y 영역 설정)
        
        // 선분과 AABB(축 정렬 결합 상자)의 교차점 수학적 계산
        let tx1 = (minX - sX) / dX;
        let tx2 = (maxX - sX) / dX;
        let tMin = Math.max(Math.min(tx1, tx2), /* ... */);
        
        // 가장 먼저 부딪히는 시간(t) 찾기
        if (hit && tMin > 0 && tMin < minT) minT = tMin;
      }
    }
  }
  // 시작점 + 방향 * 최소시간 = 예상 타격 좌표 리턴
  return { x: sX + dX * minT, y: sY + dY * minT }; 
}

단순히 마우스 방향으로 선을 긋는 것이 아니라, 벽이나 블록에 닿았을 때 선이 딱 끊기고 잔상이 남도록 만드는 레이캐스팅(Raycasting) 기법입니다. 빛(Ray)을 쏴서 사물과 만나는 교차점(Intersection)을 찾는 3D 그래픽스의 기초 연산을 2D에 적용했습니다. 수학적으로 AABB(Axis-Aligned Bounding Box) 충돌 판정을 사용하여, 발사된 공의 선분(Vector)과 사각형(Brick)이 교차하는 지점 중 가장 가까운 거리(minT)를 도출해 내는 알고리즘입니다.

5. 메인 게임 루프 (물리 엔진 및 렌더링)

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height); // 프레임 초기화

  // ... (배경, 그리드, 블록, UI 등 렌더링 로직 생략)

  // 상태가 shooting 일 때 물리 엔진 가동
  if (gameState === 'shooting') {
    let loops = isFastForward ? 2 : 1; // 2배속 버튼 구현 (연산을 2번 돌림)
    
    for (let step = 0; step < loops; step++) {
      // 1. 순차적 공 발사 로직
      if (ballsToShoot > 0 && framesSinceLastBall >= 4) {
        balls.push({ x: startX, y: startY, dx: shootDx, dy: shootDy, radius: 8 });
        ballsToShoot--;
        framesSinceLastBall = 0;
      }

      // 2. 날아가는 공들의 좌표 업데이트 및 화면 이탈 방지
      for (let i = balls.length - 1; i >= 0; i--) {
        balls[i].x += balls[i].dx;
        balls[i].y += balls[i].dy;
        // 벽 충돌 로직 및 바닥 수거 로직 수행...
        
        // 3. 블록과의 충돌 감지
        collisionDetection(balls[i]); 
      }
    }
  }

  requestAnimationFrame(draw); // 재귀 호출로 60FPS 애니메이션 구현
}

draw(); // 최초 실행

게임의 심장부인 draw() 함수입니다. 자바스크립트의 requestAnimationFrame API를 사용하여 브라우저의 주사율(일반적으로 60fps)에 맞춰 무한히 루프를 돕니다. 매 프레임마다 이전 화면을 지우고(clearRect), 공의 좌표를 속도(dx, dy)만큼 이동시킨 후 다시 그립니다. 특히 2배속(x2 ▶▶) 시스템을 눈여겨보세요. 단순히 공의 이동 거리(dx, dy)를 2배로 늘리면 충돌 판정을 건너뛰고 벽을 통과해 버리는 버그(터널링 현상)가 발생할 수 있습니다. 이를 방지하기 위해 속력을 늘리는 대신 물리 엔진 연산 루프(loops) 자체를 1프레임당 2번 실행하도록 안전하게 처리했습니다.


마치며

이상으로 스와이프 벽돌깨기 게임의 코어 로직들을 살펴보았습니다. 캔버스 API는 브라우저 위에서 상상하는 거의 모든 물리 법칙을 구현할 수 있는 강력한 도구입니다. 프론트엔드 역량을 키우고 싶으시다면 이런 작은 미니 게임을 직접 클론 코딩해 보시는 것을 강력히 추천합니다!

제 블로그 메뉴에서 게임을 직접 즐겨보시고, 코드를 응용해 보고 싶으신 분들은 언제든 댓글로 질문 남겨주세요. 감사합니다!

전체 코드

<div id="swipe-game-container" style="text-align: center; margin: 20px auto;">
  <canvas id="gameCanvas" width="400" height="500" style="background-color: #000000; border: 2px solid #fff; border-radius: 8px; touch-action: none; max-width: 100%; box-shadow: 0 4px 6px rgba(255,255,255,0.1);"></canvas>
</div>
<script>
(function() {
const canvas = document.getElementById('gameCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
function isLess(a, b) { return Math.sign(a - b) === -1; }
function isGreater(a, b) { return Math.sign(a - b) === 1; }
function isLessEq(a, b) { return Math.sign(a - b) !== 1; }
function isGreaterEq(a, b) { return Math.sign(a - b) !== -1; }
const topBoundary = 60;
const bottomBoundary = 400;
let gameState = 'idle';
let turn = 1;
let score = 1;
let ballsCount = 1;
let ballsToShoot = 0;
let framesSinceLastBall = 0;
let balls = [];
let firstBallLandedX = null;
let isFastForward = false;
let fastForwardBtn = { x: 45, y: 30, w: 60, h: 30 };
let currentStartPos = { x: canvas.width / 2, y: bottomBoundary - 8 };
let shootDx = 0;
let shootDy = 0;
let ballSpeed = 6;
const brickRowCount = 9;
const brickColumnCount = 6;
const brickWidth = 65;
const brickHeight = 36;
const brickPadding = 1;
const brickOffsetTop = 64;
const brickOffsetLeft = 2.5;
let bricks = [];
const hpColors = ['#fff0f5', '#ffe0ed', '#ffd0e5', '#ffc0dd', '#ffb0d5', '#ffa0cd', '#ff90c5', '#ff80bd', '#ff70b5', '#ff60ad', '#ff50a5', '#ff409d', '#ff3095', '#ff208d', '#ff1485'];
function initBricks() {
  for (let c = 0; c !== brickColumnCount; c++) {
    bricks[c] = [];
    for (let r = 0; r !== brickRowCount; r++) {
      bricks[c][r] = { type: 'empty', hp: 0 };
    }
  }
  generateNewRow();
}
function generateNewRow() {
  let emptySpots = [];
  for (let c = 0; c !== brickColumnCount; c++) {
    let rand = Math.floor(Math.random() * 10);
    if (isLess(rand, 6)) {
      bricks[c][1] = { type: 'brick', hp: turn };
    } else {
      bricks[c][1] = { type: 'empty', hp: 0 };
      emptySpots.push({c: c, r: 1});
    }
  }
  if (isGreater(emptySpots.length, 0)) {
    let spotIndex = Math.floor(Math.random() * emptySpots.length);
    let spot = emptySpots[spotIndex];
    bricks[spot.c][spot.r] = { type: 'item', hp: 0 };
  } else {
    let c = Math.floor(Math.random() * brickColumnCount);
    bricks[c][1] = { type: 'item', hp: 0 };
  }
}
function resetGame() {
  turn = 1;
  score = 1;
  ballsCount = 1;
  ballsToShoot = 0;
  balls = [];
  firstBallLandedX = null;
  isFastForward = false;
  currentStartPos.x = canvas.width / 2;
  initBricks();
  gameState = 'idle';
}
initBricks();
function nextTurn() {
  for (let c = 0; c !== brickColumnCount; c++) {
    if (bricks[c][8].type === 'item') ballsCount++;
  }
  turn++;
  score = turn;
  firstBallLandedX = null;
  for (let c = 0; c !== brickColumnCount; c++) {
    for (let r = brickRowCount - 1; r !== 0; r--) {
      bricks[c][r] = Object.assign({}, bricks[c][r - 1]);
    }
    bricks[c][0] = { type: 'empty', hp: 0 };
  }
  generateNewRow();
  let isGameOver = false;
  for (let c = 0; c !== brickColumnCount; c++) {
    if (bricks[c][8].type === 'brick') isGameOver = true;
  }
  if (isGameOver === true) gameState = 'gameover';
}
let isAiming = false;
let startX = 0, startY = 0, currentX = 0, currentY = 0;
function getPos(e) {
  if (e.changedTouches && e.changedTouches.length !== 0) {
    return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
  }
  return { x: e.clientX, y: e.clientY };
}
function handleStart(e) {
  if (e.cancelable) e.preventDefault();
  const rect = canvas.getBoundingClientRect();
  const pos = getPos(e);
  let cx = pos.x - rect.left;
  let cy = pos.y - rect.top;
  if (gameState === 'gameover') {
    if (isGreaterEq(cx, 125) && isLessEq(cx, 275) && isGreaterEq(cy, 270) && isLessEq(cy, 310)) {
      resetGame();
    }
    return;
  }
  let isBtnX = isGreaterEq(cx, fastForwardBtn.x - 30) && isLessEq(cx, fastForwardBtn.x + 30);
  let isBtnY = isGreaterEq(cy, fastForwardBtn.y - 20) && isLessEq(cy, fastForwardBtn.y + 20);
  if (isBtnX && isBtnY) {
    isFastForward = !isFastForward;
    return;
  }
  if (gameState !== 'idle') return;
  isAiming = true;
  startX = cx;
  startY = cy;
  currentX = startX;
  currentY = startY;
}
function handleMove(e) {
  if (!isAiming || gameState !== 'idle') return;
  if (e.cancelable) e.preventDefault();
  const rect = canvas.getBoundingClientRect();
  const pos = getPos(e);
  currentX = pos.x - rect.left;
  currentY = pos.y - rect.top;
}
function handleEnd(e) {
  if (!isAiming || gameState !== 'idle') return;
  if (e.cancelable) e.preventDefault();
  isAiming = false;
  let dx = currentX - startX;
  let dy = currentY - startY;
  let distance = Math.sqrt(dx * dx + dy * dy);
  if (isLess(distance, 10)) return;
  let dirX = dx / distance;
  let dirY = dy / distance;
  let minSin = -0.08;
  if (isGreater(dirY, minSin)) {
    dirY = minSin;
    let signX = isLess(dirX, 0) ? -1 : 1;
    dirX = signX * Math.sqrt(1 - minSin * minSin);
  }
  shootDx = dirX * ballSpeed;
  shootDy = dirY * ballSpeed;
  ballsToShoot = ballsCount;
  framesSinceLastBall = 0;
  gameState = 'shooting';
}
canvas.addEventListener('mousedown', handleStart, { passive: false });
canvas.addEventListener('mousemove', handleMove, { passive: false });
window.addEventListener('mouseup', handleEnd);
canvas.addEventListener('mouseleave', handleEnd);
window.addEventListener('mouseup', handleEnd);
canvas.addEventListener('touchstart', handleStart, { passive: false });
canvas.addEventListener('touchmove', handleMove, { passive: false });
canvas.addEventListener('touchend', handleEnd);
canvas.addEventListener('touchcancel', handleEnd);
window.addEventListener('touchend', handleEnd);
function getRayCollision(sX, sY, dX, dY) {
  let minT = 999999;
  let radius = 8;
  if (dX !== 0) {
    let tLeft = (radius - sX) / dX;
    if (isGreater(tLeft, 0) && isLess(tLeft, minT)) minT = tLeft;
    let tRight = (canvas.width - radius - sX) / dX;
    if (isGreater(tRight, 0) && isLess(tRight, minT)) minT = tRight;
  }
  if (dY !== 0) {
    let tTop = (topBoundary + radius - sY) / dY;
    if (isGreater(tTop, 0) && isLess(tTop, minT)) minT = tTop;
    let tBottom = (bottomBoundary - radius - sY) / dY;
    if (isGreater(tBottom, 0) && isLess(tBottom, minT)) minT = tBottom;
  }
  for (let c = 0; c !== brickColumnCount; c++) {
    for (let r = 0; r !== brickRowCount; r++) {
      let b = bricks[c][r];
      if (b.type === 'brick' && isGreater(b.hp, 0)) {
        let minX = c * (brickWidth + brickPadding) + brickOffsetLeft - radius;
        let maxX = minX + brickWidth + radius * 2;
        let minY = r * (brickHeight + brickPadding) + brickOffsetTop - radius;
        let maxY = minY + brickHeight + radius * 2;
        let tMin = -999999;
        let tMax = 999999;
        let hit = true;
        if (dX !== 0) {
          let tx1 = (minX - sX) / dX;
          let tx2 = (maxX - sX) / dX;
          tMin = Math.max(tMin, Math.min(tx1, tx2));
          tMax = Math.min(tMax, Math.max(tx1, tx2));
        } else if (isLess(sX, minX) || isGreater(sX, maxX)) {
          hit = false;
        }
        if (dY !== 0) {
          let ty1 = (minY - sY) / dY;
          let ty2 = (maxY - sY) / dY;
          tMin = Math.max(tMin, Math.min(ty1, ty2));
          tMax = Math.min(tMax, Math.max(ty1, ty2));
        } else if (isLess(sY, minY) || isGreater(sY, maxY)) {
          hit = false;
        }
        if (hit === true && isLessEq(tMin, tMax) && isGreater(tMin, 0) && isLess(tMin, minT)) {
          minT = tMin;
        }
      }
    }
  }
  return { x: sX + dX * minT, y: sY + dY * minT };
}
function collisionDetection(ball) {
  for (let c = 0; c !== brickColumnCount; c++) {
    for (let r = 0; r !== brickRowCount; r++) {
      let b = bricks[c][r];
      if (b.type === 'empty') continue;
      let brickX = c * (brickWidth + brickPadding) + brickOffsetLeft;
      let brickY = r * (brickHeight + brickPadding) + brickOffsetTop;
      if (b.type === 'item') {
        let itemX = brickX + brickWidth / 2;
        let itemY = brickY + brickHeight / 2;
        let distX = ball.x - itemX;
        let distY = ball.y - itemY;
        let dist = Math.sqrt(distX * distX + distY * distY);
        if (isLessEq(dist, ball.radius + 12)) {
          b.type = 'empty';
          ballsCount++;
        }
      } else if (b.type === 'brick' && isGreater(b.hp, 0)) {
        let testX = ball.x;
        let testY = ball.y;
        if (isLess(ball.x, brickX)) testX = brickX;
        else if (isGreater(ball.x, brickX + brickWidth)) testX = brickX + brickWidth;
        if (isLess(ball.y, brickY)) testY = brickY;
        else if (isGreater(ball.y, brickY + brickHeight)) testY = brickY + brickHeight;
        let distX = ball.x - testX;
        let distY = ball.y - testY;
        let distanceSq = distX * distX + distY * distY;
        if (isLessEq(distanceSq, ball.radius * ball.radius)) {
          b.hp--;
          if (b.hp === 0) b.type = 'empty';
          let overlapX = ball.radius - Math.abs(ball.x - testX);
          let overlapY = ball.radius - Math.abs(ball.y - testY);
          if (isLess(overlapX, overlapY)) {
            ball.dx *= -1;
          } else {
            ball.dy *= -1;
          }
        }
      }
    }
  }
}
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  ctx.moveTo(0, topBoundary);
  ctx.lineTo(canvas.width, topBoundary);
  ctx.moveTo(0, bottomBoundary);
  ctx.lineTo(canvas.width, bottomBoundary);
  ctx.strokeStyle = '#ffffff';
  ctx.lineWidth = 4;
  ctx.stroke();
  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 20px Arial, sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('score: ' + score, canvas.width / 2, topBoundary / 2);
  ctx.fillStyle = isFastForward ? '#22c55e' : '#ffffff';
  ctx.font = 'bold 14px Arial, sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(isFastForward ? 'x2 >>' : 'x1 >', fastForwardBtn.x, fastForwardBtn.y);
  for (let c = 0; c !== brickColumnCount; c++) {
    for (let r = 0; r !== brickRowCount; r++) {
      let b = bricks[c][r];
      let brickX = c * (brickWidth + brickPadding) + brickOffsetLeft;
      let brickY = r * (brickHeight + brickPadding) + brickOffsetTop;
      if (b.type === 'brick' && isGreater(b.hp, 0)) {
        let colorIdx = b.hp - 1;
        if (isGreater(colorIdx, 14)) colorIdx = 14;
        ctx.beginPath();
        ctx.rect(brickX, brickY, brickWidth, brickHeight);
        ctx.fillStyle = hpColors[colorIdx];
        ctx.fill();
        ctx.closePath();
        ctx.fillStyle = '#ffffff';
        ctx.font = 'bold 20px Arial, sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(b.hp, brickX + brickWidth / 2, brickY + brickHeight / 2);
      } else if (b.type === 'item') {
        let itemX = brickX + brickWidth / 2;
        let itemY = brickY + brickHeight / 2;
        ctx.beginPath();
        ctx.arc(itemX, itemY, 13, 0, Math.PI * 2);
        ctx.fillStyle = '#bbf7d0';
        ctx.fill();
        ctx.closePath();
        ctx.beginPath();
        ctx.arc(itemX, itemY, 9, 0, Math.PI * 2);
        ctx.fillStyle = '#22c55e';
        ctx.fill();
        ctx.closePath();
        ctx.fillStyle = '#fff';
        ctx.font = 'bold 16px Arial, sans-serif';
        ctx.fillText('+', itemX, itemY + 1);
      }
    }
  }
  if (gameState === 'shooting' && firstBallLandedX !== null) {
    ctx.globalAlpha = 0.4;
    ctx.beginPath();
    ctx.arc(firstBallLandedX, bottomBoundary - 8, 8, 0, Math.PI * 2);
    ctx.fillStyle = '#007bff';
    ctx.fill();
    ctx.closePath();
    ctx.globalAlpha = 1.0;
  }
  if (gameState === 'idle' || isGreater(ballsToShoot, 0)) {
    ctx.beginPath();
    ctx.arc(currentStartPos.x, currentStartPos.y, 8, 0, Math.PI * 2);
    ctx.fillStyle = '#007bff';
    ctx.fill();
    ctx.closePath();
    if (gameState === 'idle') {
      ctx.fillStyle = '#007bff';
      ctx.font = 'bold 14px Arial, sans-serif';
      ctx.fillText('x' + ballsCount, currentStartPos.x, currentStartPos.y - 15);
    }
  }
  for (let i = balls.length - 1; i !== -1; i--) {
    let b = balls[i];
    ctx.beginPath();
    ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
    ctx.fillStyle = '#007bff';
    ctx.fill();
    ctx.closePath();
  }
  if (isAiming) {
    let dx = currentX - startX;
    let dy = currentY - startY;
    let distance = Math.sqrt(dx * dx + dy * dy);
    if (isGreaterEq(distance, 10)) {
      ctx.beginPath();
      ctx.moveTo(startX, startY);
      ctx.lineTo(currentX, currentY);
      ctx.strokeStyle = '#ffc107';
      ctx.setLineDash([5, 5]);
      ctx.lineWidth = 1;
      ctx.stroke();
      let dirX = dx / distance;
      let dirY = dy / distance;
      let minSin = -0.08;
      if (isGreater(dirY, minSin)) {
        dirY = minSin;
        let signX = isLess(dirX, 0) ? -1 : 1;
        dirX = signX * Math.sqrt(1 - minSin * minSin);
      }
      let hitPoint = getRayCollision(currentStartPos.x, currentStartPos.y, dirX, dirY);
      ctx.beginPath();
      ctx.moveTo(currentStartPos.x, currentStartPos.y);
      ctx.lineTo(hitPoint.x, hitPoint.y);
      ctx.strokeStyle = '#007bff';
      ctx.setLineDash([5, 5]);
      ctx.lineWidth = 2;
      ctx.stroke();
      ctx.globalAlpha = 0.5;
      ctx.beginPath();
      ctx.arc(hitPoint.x, hitPoint.y, 8, 0, Math.PI * 2);
      ctx.fillStyle = '#007bff';
      ctx.fill();
      ctx.closePath();
      ctx.globalAlpha = 1.0;
    }
    ctx.setLineDash([]);
  }
  if (gameState === 'shooting') {
    let loops = isFastForward ? 2 : 1;
    for (let step = 0; step !== loops; step++) {
      if (isGreater(ballsToShoot, 0)) {
        if (isGreaterEq(framesSinceLastBall, 4)) {
          balls.push({ x: currentStartPos.x, y: currentStartPos.y, dx: shootDx, dy: shootDy, radius: 8 });
          ballsToShoot--;
          framesSinceLastBall = 0;
        } else {
          framesSinceLastBall++;
        }
      }
      for (let i = balls.length - 1; i !== -1; i--) {
        let b = balls[i];
        b.x += b.dx;
        b.y += b.dy;
        if (isLess(b.x - b.radius, 0)) {
          b.x = b.radius;
          b.dx *= -1;
        } else if (isGreater(b.x + b.radius, canvas.width)) {
          b.x = canvas.width - b.radius;
          b.dx *= -1;
        }
        if (isLess(b.y - b.radius, topBoundary)) {
          b.y = topBoundary + b.radius;
          b.dy *= -1;
        }
        if (isGreaterEq(b.y + b.radius, bottomBoundary)) {
          if (firstBallLandedX === null) {
            firstBallLandedX = b.x;
          }
          balls.splice(i, 1);
          continue;
        }
        collisionDetection(b);
      }
      if (balls.length === 0 && ballsToShoot === 0) {
        gameState = 'idle';
        currentStartPos.x = firstBallLandedX;
        nextTurn();
        break;
      }
    }
  }
  if (gameState === 'gameover') {
    ctx.fillStyle = 'rgba(0,0,0,0.7)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#ffffff';
    let bx = 50, by = 150, bw = 300, bh = 200, br = 15;
    ctx.beginPath();
    ctx.moveTo(bx + br, by);
    ctx.lineTo(bx + bw - br, by);
    ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + br);
    ctx.lineTo(bx + bw, by + bh - br);
    ctx.quadraticCurveTo(bx + bw, by + bh, bx + bw - br, by + bh);
    ctx.lineTo(bx + br, by + bh);
    ctx.quadraticCurveTo(bx, by + bh, bx, by + bh - br);
    ctx.lineTo(bx, by + br);
    ctx.quadraticCurveTo(bx, by, bx + br, by);
    ctx.fill();
    ctx.lineWidth = 4;
    ctx.strokeStyle = '#ff1485';
    ctx.stroke();
    ctx.fillStyle = '#ff1485';
    ctx.font = 'bold 32px Arial, sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText('GAME OVER', 200, 200);
    ctx.fillStyle = '#000000';
    ctx.font = 'bold 22px Arial, sans-serif';
    ctx.fillText('final score : ' + score, 200, 240);
    ctx.fillStyle = '#ff1485';
    let rx = 125, ry = 270, rw = 150, rh = 40, rr = 10;
    ctx.beginPath();
    ctx.moveTo(rx + rr, ry);
    ctx.lineTo(rx + rw - rr, ry);
    ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rr);
    ctx.lineTo(rx + rw, ry + rh - rr);
    ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rr, ry + rh);
    ctx.lineTo(rx + rr, ry + rh);
    ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rr);
    ctx.lineTo(rx, ry + rr);
    ctx.quadraticCurveTo(rx, ry, rx + rr, ry);
    ctx.fill();
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold 18px Arial, sans-serif';
    ctx.fillText('RETRY', 200, 290);
  }
  requestAnimationFrame(draw);
}
draw();
})();
</script>

1. 스와이프 벽돌깨기(Swipe Brick Breaker) 게임 하러 가기

👉 studyformyself 스와이프 벽돌깨기 하러가기

2. studyformyself의 다른 게임도 즐기고 싶으신가요?
👉 studyformyself 게임

1 thought on “[프론트엔드 프로젝트] 바닐라 JS로 ‘Swipe Brick Breaker(스와이프 벽돌깨기)’ 게임 만들기: 코드 리뷰 및 알고리즘 해설”

Leave a Comment