コアダンプの数だけ強くなれるよ

見習いエンジニアの備忘log

canvasで少しずつ作るブロック崩し(5/5)

前回でひとまずブロック崩しと呼べるレベルになりました(たぶん)。今回はバーを台形の形にして角に当たると横方向に跳ね返る機能を追加します。また、ソースコードの整理、当たり判定の改善などを加えてまとめます。

canvasで作るブロック崩し

左クリック: バーを下げる
右クリック: ボール出現


当たり判定の見直し

ボールとブロックの当たり判定を整理します。

実装イメージは下記のようになります。

f:id:segmentation-fault:20170918182928p:plain


これをソースコードで下記のように表現しました。

function isHitBlock(ball) {
  
  hit = HIT.NO;
  
  for (var bi = 0; bi < BLOCK.length; bi++) {
    
    if (BLOCK[bi] == null) {
      continue;
    }
    
    if (BLOCK[bi].Alive == 0) {
      continue;
    }
    
    // ブロックとの接触判定
    var bx = BLOCK[bi].X + BLOCK[bi].WIDTH/2;
    var by = BLOCK[bi].Y + BLOCK[bi].HEIGHT/2;
    if ((Math.abs(ball.X - bx) < ball.RADIUS + BLOCK[bi].WIDTH/2) &&
        (Math.abs(ball.Y - by) < ball.RADIUS + BLOCK[bi].HEIGHT/2)) {
      
      BLOCK[bi].Alive = 0;
      ball.Vy *= (-1);
      
      hit = HIT.BLOCK;
    }
  }
  
  return hit;
}


バーを台形に変更

普通の長方形だと面白みがないので台形に変えていきます。一度長方形を描いてその上から背景と同じ色の直角二等辺三角系を両端に上塗りすることで描きます。

// バーの描画
function drawBar() {
  var delay = 1;
  
  BAR.X = (mouseX + delay * BAR.X) / (delay+1);
  
  // 色設定
  ctx.fillStyle = 'rgb(255,255,255)';
  
  // バー(長方形)の描画
  ctx.fillRect(BAR.X, BAR.Y, BAR.WIDTH, BAR.HEIGHT);
  
  
  // 両端を黒塗りして台形にする
  ctx.beginPath();
  ctx.fillStyle = 'rgb(0, 0, 0)';
  ctx.moveTo(BAR.X, BAR.Y);
  ctx.lineTo(BAR.X+BAR.HEIGHT, BAR.Y);
  ctx.lineTo(BAR.X, BAR.Y+BAR.HEIGHT);
  ctx.fill();
  
  ctx.moveTo(BAR.X+BAR.WIDTH, BAR.Y);
  ctx.lineTo(BAR.X+BAR.WIDTH-BAR.HEIGHT, BAR.Y);
  ctx.lineTo(BAR.X+BAR.WIDTH, BAR.Y+BAR.HEIGHT);
  ctx.fill();
  ctx.closePath();
};


合わせてボールとバーの当たり判定に左右の両端とぶつかった場合の条件を加えます。

  // バー接触(両端)
  var cxl = BAR.X + BAR.HEIGHT/2;
  var cyl = BAR.Y + BAR.HEIGHT/2;
  var cxr = BAR.X + BAR.WIDTH - BAR.HEIGHT/2;
  var cyr = BAR.Y + BAR.HEIGHT/2;
  var hit = HIT.NO;
  
  if ((Math.abs(ball.X - cxl) < ball.RADIUS + BAR.HEIGHT/2) &&
      (Math.abs(ball.Y - cyl) < ball.RADIUS + BAR.HEIGHT/2)) {
      
    hit = HIT.BAR_LEFT;
    
  } else if ((Math.abs(ball.X - cxr) < ball.RADIUS + BAR.HEIGHT/2) &&
      (Math.abs(ball.Y - cyr) < ball.RADIUS + BAR.HEIGHT/2)) {
              
    hit = HIT.BAR_RIGHT;
    
  } else if ((BAR.X <= ball.X && ball.X <= BAR.X + BAR.WIDTH) &&
             (Math.abs(ball.Y - BAR.Y) <= BAR.HEIGHT)) {
    
    hit = HIT.BAR_CENTER;
    
  } else {
    
    hit = HIT.NO;
  }


また、バーの両端に当たった場合はボールをななめ45°上方に跳ね返すようにします。

    // バー接触後の速度計算
    ball.Vy = (ball.M * ball.Vy - BAR.M * BAR.Vy) / ball.M;
    
    // Vyが初速度より減速した場合は、初速度に戻す
    ball.Vy = Math.abs(ball.Vy) > ball.Vy0 ? (ball.Vy * BAR.E * (-1)) : (ball.Vy0 * (-1));
    
    switch(hit) {
      case HIT.BAR_LEFT:
        ball.Vx = ball.Vy * Math.cos(Math.PI * (1/4));
        ball.Vy = ball.Vy * Math.sin(Math.PI * (1/4));
        break;
      case HIT.BAR_RIGHT:
        ball.Vx = ball.Vy * Math.cos(Math.PI * (3/4));
        ball.Vy = ball.Vy * Math.sin(Math.PI * (3/4));
        break;
      case HIT.BAR_CENTOER:
      default:
        break;
    }


ソースコード

<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript">

var canvas;
var ctx;
var mouseX;
var mouseY;
var timerID;

// フィールドの情報
var FIELD = {
  'HEIGHT'  : 0,
  'WIDTH'   : 0,
  'GRAVITY' : 588, // 重力加速度(0.9 * FPS)
  'FPS'     : 60,  // frame per second
  'E'       : 0.7, 
  'E0'      : 10,
};

// バーの情報
var BAR = {
  'HEIGHT' : 10,
  'WIDTH'  : 65,
  'UNDER'  : 25,
  'X'      : 0,
  'Y'      : 0,
  'PUSH'   : 10,
  'Vx'     : 0,
  'Vx0'    : 0,
  'Vy'     : 0,
  'Vy0'    : 100,
  'M'      : 5,
  'E'      : 0.7,
  'E0'     : 10,
};

// ボールの情報
var BALL = [];
var Ball = function() {
  this.Alive  = 0;
  this.X      = 0;
  this.Y      = 0;
  this.Vy     = 0;
  this.Vx     = 0;
  this.Vx0    = 50;
  this.Vy0    = 200;
  this.RADIUS = 5;
  this.M      = 1;
  this.HUE    = 0.5;
};

// ブロックの情報
var BLOCK = [];
var Block = function() {
  this.Alive  = 0;
  this.WIDTH  = 30;
  this.HEIGHT = 10;
  this.X      = 0;
  this.Y      = 0;
  this.Red    = 255;
  this.Green  = 255;
  this.Blue   = 255;
};

// 衝突種別
var HIT = {
  WALL_LEFT  : 1,
  WALL_RIGHT : 2,
  CEILING    : 3,
  FLOOR      : 4,
  BAR_LEFT   : 5,
  BAR_RIGHT  : 6,
  BAR_CENTER : 7,
  BLOCK      : 8,
  NO         : 9
};

// 初期化処理
function initialize() {
  canvas = document.getElementById('canvas');
  if(!canvas && !canvas.getContext) {
    return false;
  }
  
  // キャンバス作成
  ctx = canvas.getContext('2d');
  FIELD.WIDTH = ctx.canvas.width ;
  FIELD.HEIGHT = ctx.canvas.height;
  
  // バーの設定
  mouseX = FIELD.WIDTH/2;  // バーの初期位置は中心
  BAR.X = mouseX;
  BAR.Y = FIELD.HEIGHT-BAR.UNDER;
  
  // ブロックの生成
  createBlocks();
  
  // 各種イベント設定
  canvas.addEventListener('mousemove', getMouseCoordinate, false);
  canvas.addEventListener('mousedown', pushBar, false);
  canvas.addEventListener('mouseup', popBar, false);
  canvas.addEventListener('contextmenu', putBall, false);
  
  // 描画開始
  timerID = setInterval(drawField, 1000/FIELD.FPS);
};


// マウス座標の更新
function getMouseCoordinate(e) {
  var rect = e.target.getBoundingClientRect();
  mouseX = Math.floor(e.clientX - rect.left);
  mouseY = Math.floor(e.clientY - rect.top);
};

// ボールの生成
function putBall(e) {
  e.preventDefault();
  
  // ボールの初期位置は中心
  var tail = BALL.length;
  BALL[tail] = new Ball();
  BALL[tail].Alive = 1;
  BALL[tail].X = BAR.X;
  BALL[tail].Y = FIELD.HEIGHT- (BAR.UNDER + BAR.HEIGHT);
  BALL[tail].Vx = BALL[tail].Vx0;
  BALL[tail].Vy = BALL[tail].Vy0 * (-1);
  
};

// バーの収縮
function pushBar(e) {
  if (!e.pageX) {
    e = event.touches[0];
  }
  
  if (e.button == 0) {
    setTimeout(pushBarEvent, 1000/FIELD.FPS);
  }
};

// バーの反発
function popBar(e) {
  if (!e.pageX) {
    e = event.touches[0];
  }
  
  if (e.button == 0) {
    setTimeout(popBarEvent, 1000/FIELD.FPS);
  }
};

// バーの収縮処理
function pushBarEvent() {
  if (BAR.Y < FIELD.HEIGHT - BAR.UNDER + BAR.PUSH) {
    BAR.Vy = BAR.Vy0;
    BAR.Y += BAR.Vy * (FIELD.FPS/1000);
    setTimeout(pushBarEvent, 1000/FIELD.FPS);
  } else {
    BAR.Vy = 0;
    BAR.Y = FIELD.HEIGHT - BAR.UNDER + BAR.PUSH;
  }
};

var BarTimer;
// バーの反発処理
function popBarEvent() {
  if (BAR.Y > FIELD.HEIGHT-BAR.UNDER) {
    BAR.Vy = BAR.Vy0 * (-1);
    BAR.Y  += BAR.Vy * (FIELD.FPS/1000);
    setTimeout(popBarEvent, 1000/FIELD.FPS);
  } else {
    BAR.Y = FIELD.HEIGHT-BAR.UNDER;
    setTimeout(resetBarSpeed, 100);
  }
};

function resetBarSpeed() {
  BAR.Vy = 0;
};

// ブロックの生成
function createBlocks() {
  var bxmax = 13;
  var bymax = 6;
  var btop  = 20;
  var bleft = 20;
  var bidx = 0;
  var bint = 5;
  
  for (var y = 0; y < bymax; y++) {
    var green = Math.floor(Math.random() * 256);
    for (var x = 0; x < bxmax; x++) {
      BLOCK[bidx] = new Block();
      BLOCK[bidx].X = x * BLOCK[bidx].WIDTH + btop + (x * bint);
      BLOCK[bidx].Y = y * BLOCK[bidx].HEIGHT + bleft + (y * bint);
      BLOCK[bidx].Alive = 1;
      BLOCK[bidx].Red = 255;
      BLOCK[bidx].Green = green;
      BLOCK[bidx].Blue = 128;
      bidx++;
    }
  }
  
};


// 画面の描画
function drawField() {
  
  calcBallP();
  
  drawBack();
  drawBall();
  drawBar();
  drawBlock();
  
};


// ボール位置計算
function calcBallP() {
  
  for (var i = 0; i < BALL.length; i++) {
    
    if (BALL[i] == null) {
      continue;
    }
    
    // 生存しているボールのみ計算
    if (BALL[i].Alive == 1) {
      
      var hit = HIT.NO;
      
      // 壁・天井・床 当たり判定
      if (hit == HIT.NO) {
        hit = isHitFrame(BALL[i]);
      }
      
      // バー当たり判定
      if (hit == HIT.NO) {
        hit = isHitBar(BALL[i]);
      }
      
      // ブロック当たり判定
      if (hit == HIT.NO) {
        hit = isHitBlock(BALL[i]);
      }
      
      // ボールの速度更新
      // Y成分
      BALL[i].Vy += FIELD.GRAVITY * (1/FIELD.FPS);
      BALL[i].Y  += BALL[i].Vy * (1/FIELD.FPS);
      
      // X成分
      BALL[i].X += BALL[i].Vx * (1/FIELD.FPS);
    }
  }
  
  deleteAllDeadBall();
};

function isHitFrame(ball) {
  
  var hit = HIT.NO;
  
  // 床・天井接触
  if (ball.Y <= 0 || FIELD.HEIGHT <= ball.Y) {
    if (ball.Y <= 0) {
      
      // 天井
      ball.Y = FIELD.E0;
      ball.Vy = ball.Vy * FIELD.E * (-1)
      
      hit =  HIT.CEILING;
    } else {
      // 床
      // 床に接触したボールは死亡
      ball.Alive = 0;
      ball.Vx = 0;
      ball.Vy = 0;
      
      hit =  HIT.FLOOR;
    }
  }
  
  if (hit == HIT.NO) {
    // 壁接触
    if (ball.X <= 0 || FIELD.WIDTH <= ball.X) {
      if (ball.X <= 0) {
        ball.X = FIELD.E0;
        hit = HIT.WALL_LEFT;
      } else {
        ball.X = FIELD.WIDTH - FIELD.E0;
        hit = HIT.WALL_RIGHT;
      }
      
      ball.Vx = ball.Vx * (-1);
    }
  }
  
  return hit;
}


function isHitBar(ball) {

  // バー接触(両端)
  var cxl = BAR.X + BAR.HEIGHT/2;
  var cyl = BAR.Y + BAR.HEIGHT/2;
  var cxr = BAR.X + BAR.WIDTH - BAR.HEIGHT/2;
  var cyr = BAR.Y + BAR.HEIGHT/2;
  var hit = HIT.NO;
  
  if ((Math.abs(ball.X - cxl) < ball.RADIUS + BAR.HEIGHT/2) &&
      (Math.abs(ball.Y - cyl) < ball.RADIUS + BAR.HEIGHT/2)) {
      
    hit = HIT.BAR_LEFT;
    
  } else if ((Math.abs(ball.X - cxr) < ball.RADIUS + BAR.HEIGHT/2) &&
      (Math.abs(ball.Y - cyr) < ball.RADIUS + BAR.HEIGHT/2)) {
              
    hit = HIT.BAR_RIGHT;
    
  } else if ((BAR.X <= ball.X && ball.X <= BAR.X + BAR.WIDTH) &&
             (Math.abs(ball.Y - BAR.Y) <= BAR.HEIGHT)) {
    
    hit = HIT.BAR_CENTER;
    
  } else {
    
    hit = HIT.NO;
  }
  
  if (hit != HIT.NO) {
    // バーとボールの境界でバタつきを防ぐための処置
    ball.Y = BAR.Y - BAR.E0;
    
    // バー接触後の速度計算
    ball.Vy = (ball.M * ball.Vy - BAR.M * BAR.Vy) / ball.M;
    
    // Vyが初速度より減速した場合は、初速度に戻す
    ball.Vy = Math.abs(ball.Vy) > ball.Vy0 ? (ball.Vy * BAR.E * (-1)) : (ball.Vy0 * (-1));
    
    switch(hit) {
      case HIT.BAR_LEFT:
        ball.Vx = ball.Vy * Math.cos(Math.PI * (1/3));
        ball.Vy = ball.Vy * Math.sin(Math.PI * (1/3));
        break;
      case HIT.BAR_RIGHT:
        ball.Vx = ball.Vy * Math.cos(Math.PI * (2/3));
        ball.Vy = ball.Vy * Math.sin(Math.PI * (2/3));
        break;
      case HIT.BAR_CENTOER:
      default:
        break;
    }
  }
  
  return hit;
}

function isHitBlock(ball) {
  
  hit = HIT.NO;
  
  for (var bi = 0; bi < BLOCK.length; bi++) {
    
    if (BLOCK[bi] == null) {
      continue;
    }
    
    if (BLOCK[bi].Alive == 0) {
      continue;
    }
    
    // ブロックとの接触判定
    var bx = BLOCK[bi].X + BLOCK[bi].WIDTH/2;
    var by = BLOCK[bi].Y + BLOCK[bi].HEIGHT/2;
    if ((Math.abs(ball.X - bx) < ball.RADIUS + BLOCK[bi].WIDTH/2) &&
        (Math.abs(ball.Y - by) < ball.RADIUS + BLOCK[bi].HEIGHT/2)) {
      
      BLOCK[bi].Alive = 0;
      ball.Vy *= (-1);
      
      hit = HIT.BLOCK;
    }
  }
  
  return hit;
}

// 死亡したボールを削除
function deleteAllDeadBall() {
  var isDeadBall = 1;
  while(isDeadBall != 0) {
    isDeadBall = 0;
    for (var i = 0; i < BALL.length; i++) {
      if (BALL[i] == null) {
        continue;
      }
      
      if(BALL[i].Alive == 0) {
        delete BALL[i];
        BALL.splice(i,1);
        isDeadBall = 1;
        break;
      }
    }
  }
}


function drawBack() {
  ctx.fillStyle = 'rgb(0, 0, 0)';
  ctx.fillRect(0, 0, FIELD.WIDTH, FIELD.HEIGHT);
};


// ボールの描画
function drawBall() {
  
  ctx.save();
  
  // 生存しているボールの数だけ描画
  for(var i = 0; i < BALL.length; i++) {
    
    if (BALL[i] == null) {
      continue;
    }
    
    if (BALL[i].Alive) {
      // 円の描画設定
      ctx.beginPath();
      ctx.arc(BALL[i].X, BALL[i].Y, BALL[i].RADIUS, 0, 2*Math.PI, true);
      ctx.closePath();
      
      // 色設定
      BALL[i].HUE += 0.5;
      ctx.strokeStyle = 'hsl(' + BALL[i].HUE + ', 50%, 50%)';
      ctx.fillStyle = 'hsl(' + BALL[i].HUE + ', 50%, 50%)';
      ctx.shadowColor = 'hsl(' + BALL[i].HUE + ', 50%, 50%)';
    }
    
    // 描画実行
    ctx.stroke();
    ctx.fill();
  
  }
  
  ctx.restore();
};

// バーの描画
function drawBar() {
  var delay = 1;
  
  BAR.X = (mouseX + delay * BAR.X) / (delay+1);
  
  // 色設定
  ctx.fillStyle = 'rgb(255,255,255)';
  
  // バー(長方形)の描画
  ctx.fillRect(BAR.X, BAR.Y, BAR.WIDTH, BAR.HEIGHT);
  
  
  // 両端を黒塗りして台形にする
  ctx.beginPath();
  ctx.fillStyle = 'rgb(0, 0, 0)';
  ctx.moveTo(BAR.X, BAR.Y);
  ctx.lineTo(BAR.X+BAR.HEIGHT, BAR.Y);
  ctx.lineTo(BAR.X, BAR.Y+BAR.HEIGHT);
  ctx.fill();
  
  ctx.moveTo(BAR.X+BAR.WIDTH, BAR.Y);
  ctx.lineTo(BAR.X+BAR.WIDTH-BAR.HEIGHT, BAR.Y);
  ctx.lineTo(BAR.X+BAR.WIDTH, BAR.Y+BAR.HEIGHT);
  ctx.fill();
  ctx.closePath();
};

// ブロックの描画
function drawBlock() {
  
  for (var i = 0; i < BLOCK.length; i++) {
    if (BLOCK[i] == null) {
      continue;
    }
    
    if (BLOCK[i].Alive) {
      ctx.fillStyle = 'rgb('+ BLOCK[i].Red +','+ BLOCK[i].Green +','+ BLOCK[i].Blue +')';
      ctx.fillRect(BLOCK[i].X, BLOCK[i].Y, BLOCK[i].WIDTH, BLOCK[i].HEIGHT);
    }
  }
}

function reset_game() {
  var isBallRemain = 1;
  while(BALL.length != 0) {
    delete BALL[0];
    BALL.splice(0,1);
  }
  
  clearInterval(timerID);
  
  initialize();
}

// 初期化イベント
window.addEventListener('load', initialize, false);

</script>
</head>
<body>
<canvas id='canvas' width=500 height=300></canvas>
<form><input type="button" name="reset" value="Reset" onClick="reset_game()"></form>
<p>
左クリック: バーを下げる <br />
右クリック: ボール出現
</p>
</body>
</html>


まとめ

この程度の機能ですが、スキル不足もあり実装にだいぶ時間がかかってしまい…(^-^;) けれど実際に作ってみることで得る物はたくさんありました。

また、いずれJavascript/HTML/CSSの理解が深まった時に見返してみて改善を加えたいと思います。 今度作るときは残機やスコア表示、複数ステージ、ランキング機能、サウンドエフェクトなんかをつけてみたいですね。


関連ページ

canvasで少しずつ作るブロック崩し(1/5) - Segmentation Fault
canvasで少しずつ作るブロック崩し(2/5) - Segmentation Fault
canvasで少しずつ作るブロック崩し(3/5) - Segmentation Fault
canvasで少しずつ作るブロック崩し(その4) - Segmentation Fault