砂遊び

砂遊び | ひまあそび-ミニゲーム hi0a.com

view source

JavaScript

document.title = '砂遊び';

const demoElement = document.getElementById('demo');
const canvas = document.createElement('canvas');
demoElement.appendChild(canvas);
const ctx = canvas.getContext("2d");
const tileSize = 80;

function resizeCanvasToVmin() {
  canvas.width = tileSize * 16;
  canvas.height = tileSize * 9;
/*
  // 衝突ブロックの更新(再定義)
  collisionBlocks.length = 0;
  collisionBlocks.push(
    { x: canvas.width - 1, y: 0, width: 2, height: canvas.height },
    { x: scoreBox.x, y: scoreBox.y, width: scoreBox.width, height: scoreBox.height },
    { x: 0, y: 0, width: tileSize, height: canvas.height }
  );
*/
}
window.addEventListener("resize", resizeCanvasToVmin);
resizeCanvasToVmin();

Number.prototype.clamp = function (_min, _max) {
  return Math.min(Math.max(this, _min), _max);
};

const initialGameState = {
  sands: [],
  selectedType: null,
  flashTimer: 0,
  gameOver: false,
  stageNumber: 1,
  money: 1000,
  unitMax: 20000,
  messageTimer: 0,
  recentMessage: "",
};
let isPointerDown = false;
let spawnInterval = null;
let pointerPos = { x: 0, y: 0 };
Object.assign(window, structuredClone(initialGameState));

const scoreBox = {
  x: 0,
  y: canvas.height - tileSize - 2,
  width: canvas.width,
  height: tileSize + 4
};

const enemyTypes = {
  normal: { label: "歩兵", color: "red" },
  archer: { label: "弓兵", color: "limegreen" },
  fast: { label: "騎兵", color: "orange" },
  tank: { label: "重装", color: "#2F4F4F" },
  abnormal: { label: "工兵", color: "#aa0000" },
  ant: { label: "偵察", color: "#191919" },
  boss: { isBoss: true, label: "大型種", color: "darkgray" },
};

const collisionBlocks = [
  // 右端ブロック
  { x: canvas.width - canvas.width/4, y: 0, width: canvas.width/4, height: canvas.height },

  // スコアボックス(下部)
  { x: scoreBox.x, y: scoreBox.y-tileSize*2, width: scoreBox.width, height: scoreBox.height },

  // 左側のタイプメニュー
  { x: 0, y: 0, width: tileSize, height: canvas.height },
];

class Sand {
  constructor(type = "water") {
    this.x = Math.random() * canvas.width;
    this.y = 0;
    this.size = 8;
    this.color = '#000';

    this.vx = (Math.random() - 0.5) * 1;
    this.vy = 0;

    this.gravity = 0.1;
    this.bounce = 0.03;
    this.friction = 1;
  }

  update() {
    this.vy += this.gravity;

      this.vx = this.vx.clamp(-10,10);
      this.vx = this.vx.clamp(-10,10);
    let nextX = this.x + this.vx;
    let nextY = this.y + this.vy;

    // スイープ判定(移動中に交差するブロックがあれば手前で止める)
    for (let block of collisionBlocks) {
      const hitX = this.sweepAxisCollision(this.x, nextX, this.size, block.x, block.width);
      if (hitX !== null) {
        nextX = hitX;
        this.vx *= -this.bounce;
        this.vy *= this.friction;
      }

      const hitY = this.sweepAxisCollision(this.y, nextY, this.size, block.y, block.height);
      if (hitY !== null) {
        nextY = hitY;
        this.vy *= -this.bounce;
        this.vx *= this.friction;
      }
    }

    this.x = nextX;
    this.y = nextY;
  }

  sweepAxisCollision(pos1, pos2, size, blockStart, blockSize) {
    const half = size / 2;
    const blockEnd = blockStart + blockSize;

    if (pos1 + half <= blockStart && pos2 + half >= blockStart) {
      return blockStart - half; // →方向に接触
    }
    if (pos1 - half >= blockEnd && pos2 - half <= blockEnd) {
      return blockEnd + half; // ←方向に接触
    }

    return null; // 接触なし
  }


  isCollidingWith(block) {
    return (
      this.x + this.size / 2 > block.x &&
      this.x - this.size / 2 < block.x + block.width &&
      this.y + this.size / 2 > block.y &&
      this.y - this.size / 2 < block.y + block.height
    );
  }
  resolveBlockCollision() {
    for (let block of collisionBlocks) {
      if (this.isCollidingWith(block)) {
        this.resolveCollision(block);
      }
    }
  }
  resolveSandCollision(other) {
    const dx = other.x - this.x;
    const dy = other.y - this.y;
    let dist = Math.sqrt(dx * dx + dy * dy);
    const minDist = (this.size + other.size) / 2;
    // 距離ゼロは強制的に小さくして回避
    if (dist < 0.01) {
      dist = 0.01;
    }
    if (dist < minDist && dist > 0.01) {
      const overlap = minDist - dist;

      // 押し出しベクトル
      let nx = dx / dist;
      let ny = dy / dist;

      nx = nx.clamp(-1,1);
      ny = ny.clamp(-1,1);

      // 押し返し(半分ずつ)
      const push = overlap / 2;
      this.x -= nx * push;
      this.y -= ny * push;
      other.x += nx * push;
      other.y += ny * push;

      // 簡単な反発(速度交換)
      const bounceFactor = 0.2;
      const vdx = this.vx - other.vx;
      const vdy = this.vy - other.vy;
      const impact = vdx * nx + vdy * ny;

      if (impact > 0) {
        let impulse = impact * bounceFactor;
        impulse = impulse.clamp(-10,10);
        this.vx -= impulse * nx;
        this.vy -= impulse * ny;
        other.vx += impulse * nx;
        other.vy += impulse * ny;
      }
    }
  }


  resolveCollision(block) {
    // 最も近い方向に押し出し
    const dx = (this.x - (block.x + block.width / 2));
    const dy = (this.y - (block.y + block.height / 2));
    const absDX = Math.abs(dx);
    const absDY = Math.abs(dy);

    if (absDX > absDY) {
      // 横方向の衝突
      if (dx > 0) {
        this.x = block.x + block.width + this.size / 2;
      } else {
        this.x = block.x - this.size / 2;
      }
      this.vx *= -this.bounce;
      this.vy *= this.friction;
    } else {
      // 縦方向の衝突
      if (dy > 0) {
        this.y = block.y + block.height + this.size / 2;
      } else {
        this.y = block.y - this.size / 2;
      }
      this.vy *= -this.bounce;
      this.vx *= this.friction;
      this.vx *= 0.1;
        this.vy = this.vy.clamp(-10,10);
        this.vx = this.vx.clamp(-10,10);
    }
  }



  isNearlyStopped() {
    return Math.abs(this.vx) < 0.05 && Math.abs(this.vy) < 0.05;
  }

  tryStickToNearbySand(sands) {
    if (!this.isNearlyStopped()) return;

    for (let other of sands) {
      if (other === this) continue;

      const dx = other.x - this.x;
      const dy = other.y - this.y;

      const distance = Math.sqrt(dx * dx + dy * dy);
      const threshold = this.size + 0.3;

      if (distance < threshold) {
        // くっつく位置にスナップ
        const offsetY = other.y + this.size;
        if (Math.abs(dx) < this.size / 2 && this.y < offsetY) {
          this.y = offsetY;
          this.vx = 0;
          this.vy = 0;
          return;
        }
      }
    }
  }


  isOutOfBounds() {
    return (
      this.x < -this.size ||
      this.x > canvas.width + this.size ||
      this.y > canvas.height + this.size ||
      this.y < -this.size
    );
  }

  draw() {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);
  }
}


function drawGrid() {
  ctx.strokeStyle = "rgba(0,0,0,0.05)";
  for (let x = 0; x < canvas.width; x += tileSize) {
    ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
  }
  for (let y = 0; y < canvas.height; y += tileSize) {
    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
  }
}

function drawTypeMenu() {
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, tileSize, canvas.height);

  const allTypes = Object.entries(enemyTypes).filter(([key, val]) => !val.isBoss);
  const types = [{ key: null, label: "ランダム", color: "gray" },
                 ...allTypes.map(([key, val]) => ({ key, label: val.label, color: val.color }))];

  const itemHeight = tileSize;
  types.forEach((entry, i) => {
    const y = i * itemHeight;
    const isSelected = selectedType === entry.key;
    if (isSelected) {
      ctx.fillStyle = "white";
      ctx.fillRect(0, y, tileSize, itemHeight);
    }
    ctx.fillStyle = entry.color;
    ctx.beginPath();
    ctx.arc(tileSize / 2, y + itemHeight / 2, 14, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = isSelected ? "black" : "white";
    ctx.font = "10px sans-serif";
    ctx.textAlign = "center";
    ctx.fillText(entry.label, tileSize / 2, y + itemHeight - 4);
  });
}

function drawScore() {
  ctx.fillStyle = "black";
  ctx.fillRect(scoreBox.x, scoreBox.y, scoreBox.width, scoreBox.height + 2);
  ctx.fillStyle = "white";
  ctx.font = "20px sans-serif";
  ctx.textAlign = "center";
  ctx.fillText(`Stage: ${stageNumber}`, scoreBox.x + 60, scoreBox.y + 25);
  ctx.textAlign = "right";
  ctx.fillText(`G : ${money}`, scoreBox.x + scoreBox.width - 240, scoreBox.y + 25);
  ctx.fillText(`■ :  ${sands.length} / ${unitMax}`, scoreBox.x + scoreBox.width - 80, scoreBox.y + 25);
  if (messageTimer > 0) {
    ctx.fillStyle = "lightyellow";
    ctx.textAlign = "left";
    ctx.font = "16px sans-serif";
    ctx.fillText(recentMessage, scoreBox.x + 160, scoreBox.y + 25);
    messageTimer--;
  }
}

function showMessage(text) {
  recentMessage = text;
  messageTimer = 1200;
}

function getCanvasPos(e) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  return {
    x: (e.clientX - rect.left) * scaleX,
    y: (e.clientY - rect.top) * scaleY
  };
}

function startSpawning() {
  isPointerDown = true;
  spawnUnitAt(pointerPos.x, pointerPos.y);
  spawnInterval = setInterval(() => {
    if (isPointerDown) {
      spawnUnitAt(pointerPos.x, pointerPos.y);
    }
  }, 30); // ← 間隔調整可
}

function stopSpawning() {
  isPointerDown = false;
  clearInterval(spawnInterval);
}

function updatePointer(e) {
  const pos = getCanvasPos(e);
  pointerPos.x = pos.x;
  pointerPos.y = pos.y;
}


function spawnUnitAt(x, y) {
  if (sands.length >= unitMax) return;
  const sand = new Sand("water");
  sand.x = x;
  sand.y = y;
  sands.push(sand);
}

canvas.addEventListener("click", (e) => {
  const { x, y } = getCanvasPos(e);
  if (x < tileSize) {
    const index = Math.floor(y / tileSize);
    const keys = Object.keys(enemyTypes).filter(k => !enemyTypes[k].isBoss);
    selectedType = index === 0 ? null : keys[index - 1];
  } else {
    spawnUnitAt(x, y);
  }
});

function resetGame() {
  Object.assign(window, structuredClone(initialGameState));
  showMessage("すなあそび!");
}

function gameLoop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#eeeeee";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  drawGrid();
  for (let sand of sands){
    sand.update();
    sand.tryStickToNearbySand(sands); // ← 追加
  } 
  // 粒子同士の衝突処理(最適化なし、全チェック)
  for (let i = 0; i < sands.length; i++) {
    for (let j = i + 1; j < sands.length; j++) {
      sands[i].resolveSandCollision(sands[j]);
    }
  }
  // Sandとブロックの衝突(押し出し後の位置で再判定!)
  for (let sand of sands) {
    sand.resolveBlockCollision();
  }
  for (let sand of sands) sand.draw();

  sands = sands.filter(s => !s.isOutOfBounds());

  drawTypeMenu();
  drawScore();
  if (gameOver) {
    ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "white";
    ctx.font = "64px 'Roboto Mono', monospace";
    ctx.textAlign = "center";
    ctx.fillText("GAME OVER", canvas.width / 2, canvas.height / 2);
  }
  requestAnimationFrame(gameLoop);
}

resetGame();
gameLoop();



canvas.addEventListener("mousedown", (e) => {
  updatePointer(e);

  // メニュー部分のクリックを無視
  if (pointerPos.x < tileSize) return;

  startSpawning();
});
canvas.addEventListener("mousemove", (e) => {
  if (isPointerDown) updatePointer(e);
});
canvas.addEventListener("mouseup", stopSpawning);
canvas.addEventListener("mouseleave", stopSpawning);

// スマホ・タッチ操作にも対応
canvas.addEventListener("touchstart", (e) => {
  updatePointer(e);
  startSpawning();
});
canvas.addEventListener("touchmove", (e) => {
  if (isPointerDown) updatePointer(e);
});
canvas.addEventListener("touchend", stopSpawning);
canvas.addEventListener("touchcancel", stopSpawning);

CSS

HTML

ページのソースを表示 : Ctrl+U , DevTools : F12

view-source:https://hi0a.com/demo/-js/js-playing-sand/

ABOUT

hi0a.com 「ひまあそび」は無料で遊べるミニゲームや便利ツールを公開しています。

プログラミング言語の動作デモやWEBデザイン、ソースコード、フロントエンド等の開発者のための技術を公開しています。

必要な機能の関数をコピペ利用したり勉強に活用できます。

プログラムの動作サンプル結果は画面上部に出力表示されています。

環境:最新のブラウザ GoogleChrome / Windows / Android / iPhone 等の端末で動作確認しています。

画像素材や音素材は半分自作でフリー素材配布サイトも利用しています。LINK参照。

仕様変更、API廃止等、実験途中で動かないページもあります。