【JavaScript】ゲームの戦闘っぽいデモ

素材提供→【Rド】
http://rpgdot3319.g1.xrea.com/

プレイヤー側と NPC 側の両者が自動的に敵に近づき、一定距離で戦闘を開始するシンプルな戦闘デモです。モンスターはそれぞれランダムな画像を読み込み、HP・攻撃力・防御力もランダムに設定されます。

1. ソースコード

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Monster Battle Game Demo</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      background-color: #222;
      color: #fff;
      font-family: sans-serif;
    }
    #gameArea {
      position: relative;
      width: 100vw;
      height: 100vh;
      overflow: hidden;
      background-image: url('/img/background.png');
      background-size: cover;
      background-position: center;
      background-repeat: no-repeat;
      position: relative;
      margin: auto;
    }
    /* 画像本来のサイズを利用するため、固定サイズ指定はしていません */
    .monster-container {
      position: absolute;
      pointer-events: none;
    }
    .monster-container img {
      display: block;
    }
    .hp-bar {
      position: absolute;
      top: -8px;
      left: 0;
      width: 100%;
      height: 5px;
      border: 1px solid #000;
      box-sizing: border-box;
    }
    #message {
      position: absolute;
      top: 10px;
      left: 10px;
      z-index: 1000;
      background: rgba(0,0,0,0.7);
      padding: 5px 10px;
      border-radius: 4px;
    }
  </style>
</head>
<body>
  <div id="gameArea"></div>
  <div id="message"></div>
  <script>
    // 定数
    const gameArea = document.getElementById("gameArea");
    const messageEl = document.getElementById("message");
    let monsters = [];
    
    // モンスターの出現数
    const NPC_COUNT = 10;
    const PLAYER_COUNT = 10;

    const GAME_WIDTH = gameArea.offsetWidth;
    const GAME_HEIGHT = gameArea.offsetHeight;
    const MOVE_SPEED = 2;              // 敵に向かう基本移動速度
    const COLLISION_FACTOR = 1.6;      // 戦闘発生判定用の中心距離の係数
    const ATTACK_COOLDOWN = 1000;      // 戦闘処理のクールダウン (ms)
    const SPAWN_MARGIN = 20;           // 初期配置時の余白
    const REPULSE_STRENGTH = 10;       // 戦闘時の弾きの強さ

    // 画像未読み込み時のデフォルトサイズ
    let DEFAULT_MONSTER_WIDTH = 50;
    let DEFAULT_MONSTER_HEIGHT = 50;

    class Monster {
      constructor(type, x, y) {
        this.type = type; // "player" または "npc"
        this.maxHp = Math.floor(Math.random() * 50) + 50; // HP: 50~100
        this.hp = this.maxHp;
        this.attack = Math.floor(Math.random() * 10) + 5;   // 攻撃力: 5~14
        this.defense = Math.floor(Math.random() * 5) + 1;     // 防御力: 1~5

        // ランダム画像番号(001~250)
        const num = ("000" + (Math.floor(Math.random() * 250) + 1)).slice(-3);
        this.imgSrc = `/img/mon_${num}.gif`;

        // コンテナ作成(HPバー+画像)
        this.container = document.createElement("div");
        this.container.classList.add("monster-container");

        // HPバー作成
        this.hpBar = document.createElement("div");
        this.hpBar.classList.add("hp-bar");
        this.hpBar.style.backgroundColor = (this.type === "npc") ? "red" : "green";
        this.container.appendChild(this.hpBar);

        // 画像要素の作成
        this.el = document.createElement("img");
        this.el.src = this.imgSrc;
        if (this.type === "player") {
          this.el.style.transform = "scaleX(-1)";
        }
        this.container.appendChild(this.el);

        // 初期位置設定
        this.x = x;
        this.y = y;
        this.container.style.left = this.x + "px";
        this.container.style.top = this.y + "px";

        // 初期はデフォルトサイズ(画像読み込み前)
        this.width = DEFAULT_MONSTER_WIDTH;
        this.height = DEFAULT_MONSTER_HEIGHT;

        this.lastAttackTime = 0;
        this.vx = 0;
        this.vy = 0;
        this.flashTimeout = null;

        // 画像読み込み完了後、自然サイズへ更新
        this.el.onload = () => {
          this.width = this.el.naturalWidth;
          this.height = this.el.naturalHeight;
          this.container.style.width = this.width + "px";
          this.container.style.height = this.height + "px";
          // spawn位置の計算に利用されるグローバル値も更新(初回のみ)
          if (DEFAULT_MONSTER_WIDTH === 50 && DEFAULT_MONSTER_HEIGHT === 50) {
            DEFAULT_MONSTER_WIDTH = this.width;
            DEFAULT_MONSTER_HEIGHT = this.height;
          }
        };
      }
      
      move(allMonsters) {
        // -- 分離(Separation)アルゴリズム --
        let sepForce = { x: 0, y: 0 };
        let count = 0;
        const currentWidth = this.width || DEFAULT_MONSTER_WIDTH;
        const currentHeight = this.height || DEFAULT_MONSTER_HEIGHT;
        const thisCenter = {
          x: this.x + currentWidth / 2,
          y: this.y + currentHeight / 2
        };
        const sepThreshold = Math.max(currentWidth, currentHeight) * 0.8;
        allMonsters.forEach(other => {
          if (other === this) return;
          const otherWidth = other.width || DEFAULT_MONSTER_WIDTH;
          const otherHeight = other.height || DEFAULT_MONSTER_HEIGHT;
          const otherCenter = {
            x: other.x + otherWidth / 2,
            y: other.y + otherHeight / 2
          };
          const dx = thisCenter.x - otherCenter.x;
          const dy = thisCenter.y - otherCenter.y;
          const dist = Math.sqrt(dx * dx + dy * dy);
          if (dist < sepThreshold && dist > 0) {
            sepForce.x += (dx / dist) * (sepThreshold - dist);
            sepForce.y += (dy / dist) * (sepThreshold - dist);
            count++;
          }
        });
        if (count > 0) {
          sepForce.x /= count;
          sepForce.y /= count;
          const separationStrength = 0.7;
          this.vx += sepForce.x * separationStrength;
          this.vy += sepForce.y * separationStrength;
        }
        // ---------------------------------------

        // 次の位置を計算
        let newX = this.x + this.vx;
        let newY = this.y + this.vy;
        
        // 画面外に出ないように判定・反転(バウンド)させる
        if (newX < 0) {
          newX = 0;
          this.vx = Math.abs(this.vx); // 内向きに反転
        }
        if (newX > GAME_WIDTH - currentWidth) {
          newX = GAME_WIDTH - currentWidth;
          this.vx = -Math.abs(this.vx);
        }
        if (newY < 0) {
          newY = 0;
          this.vy = Math.abs(this.vy);
        }
        if (newY > GAME_HEIGHT - currentHeight) {
          newY = GAME_HEIGHT - currentHeight;
          this.vy = -Math.abs(this.vy);
        }
        
        this.x = newX;
        this.y = newY;
        this.container.style.left = this.x + "px";
        this.container.style.top = this.y + "px";
        // モンスターの下端の座標を z-index に反映
        this.container.style.zIndex = Math.floor(this.y + currentHeight);
      }
      
      flashDamage() {
        if (this.flashTimeout) {
          clearTimeout(this.flashTimeout);
        }
        this.container.style.backgroundColor = "red";
        this.flashTimeout = setTimeout(() => {
          this.container.style.backgroundColor = "";
          this.flashTimeout = null;
        }, 100);
      }
      
      updateHpBar() {
        const ratio = Math.max(this.hp, 0) / this.maxHp;
        this.hpBar.style.width = (ratio * 100) + "%";
      }
      
      battle(opponent, now) {
        if (now - this.lastAttackTime < ATTACK_COOLDOWN) return;
        const damageToOpp = Math.max(1, this.attack - opponent.defense);
        const damageToSelf = Math.max(1, opponent.attack - this.defense);
        opponent.hp -= damageToOpp;
        this.hp -= damageToSelf;
        this.flashDamage();
        opponent.flashDamage();
        this.updateHpBar();
        opponent.updateHpBar();
        console.log(`${this.type}(${this.hp}) vs ${opponent.type}(${opponent.hp})`);
        this.lastAttackTime = now;
        opponent.lastAttackTime = now;
        
        // 戦闘時、中心点からの距離に応じて反発
        const thisCenter = {
          x: this.x + (this.width || DEFAULT_MONSTER_WIDTH) / 2,
          y: this.y + (this.height || DEFAULT_MONSTER_HEIGHT) / 2
        };
        const oppCenter = {
          x: opponent.x + (opponent.width || DEFAULT_MONSTER_WIDTH) / 2,
          y: opponent.y + (opponent.height || DEFAULT_MONSTER_HEIGHT) / 2
        };
        const dx = oppCenter.x - thisCenter.x;
        const dy = oppCenter.y - thisCenter.y;
        const dist = Math.sqrt(dx * dx + dy * dy) || 1;
        this.vx = - (dx / dist) * REPULSE_STRENGTH;
        this.vy = - (dy / dist) * REPULSE_STRENGTH;
        opponent.vx = (dx / dist) * REPULSE_STRENGTH;
        opponent.vy = (dy / dist) * REPULSE_STRENGTH;
      }
    }
    
    // spawn時の配置重なりを防ぐための配列
    const spawnPositions = [];
    function generateSpawnPosition(type) {
      const defW = DEFAULT_MONSTER_WIDTH;
      const defH = DEFAULT_MONSTER_HEIGHT;
      let xMin, xMax;
      if (type === "npc") {
        xMin = 0;
        xMax = GAME_WIDTH / 2 - defW;
      } else {
        xMin = GAME_WIDTH / 2;
        xMax = GAME_WIDTH - defW;
      }
      const yMin = 0;
      const yMax = GAME_HEIGHT - defH;
      let attempt = 0;
      while (attempt < 1000) {
        const x = Math.random() * (xMax - xMin) + xMin;
        const y = Math.random() * (yMax - yMin) + yMin;
        let overlap = spawnPositions.some(pos => {
          return Math.abs(pos.x - x) < defW + SPAWN_MARGIN &&
                 Math.abs(pos.y - y) < defH + SPAWN_MARGIN;
        });
        if (!overlap) {
          spawnPositions.push({ x, y });
          return { x, y };
        }
        attempt++;
      }
      return { x: xMin, y: yMin };
    }
   
    // NPC の生成(画面左半分)
    for (let i = 0; i < NPC_COUNT; i++) {
      const pos = generateSpawnPosition("npc");
      const m = new Monster("npc", pos.x, pos.y);
      monsters.push(m);
      gameArea.appendChild(m.container);
    }
    // プレイヤー の生成(画面右半分)
    for (let i = 0; i < PLAYER_COUNT; i++) {
      const pos = generateSpawnPosition("player");
      const m = new Monster("player", pos.x, pos.y);
      monsters.push(m);
      gameArea.appendChild(m.container);
    }
    
    function updateGame() {
      const now = Date.now();
      
      // 各モンスターは、敵方向へ追従しながら移動
      monsters.forEach(m => {
        let target = null;
        let minDist = Infinity;
        monsters.forEach(o => {
          if (o.type !== m.type) {
            const mCenter = {
              x: m.x + (m.width || DEFAULT_MONSTER_WIDTH) / 2,
              y: m.y + (m.height || DEFAULT_MONSTER_HEIGHT) / 2
            };
            const oCenter = {
              x: o.x + (o.width || DEFAULT_MONSTER_WIDTH) / 2,
              y: o.y + (o.height || DEFAULT_MONSTER_HEIGHT) / 2
            };
            const dx = oCenter.x - mCenter.x;
            const dy = oCenter.y - mCenter.y;
            const dist = Math.sqrt(dx * dx + dy * dy);
            if (dist < minDist) {
              minDist = dist;
              target = o;
            }
          }
        });
        if (target) {
          const dx = target.x - m.x;
          const dy = target.y - m.y;
          const len = Math.sqrt(dx * dx + dy * dy);
          if (len > 0) {
            m.vx = (dx / len) * MOVE_SPEED;
            m.vy = (dy / len) * MOVE_SPEED;
          }
        }
        m.move(monsters);
      });
      
      // 戦闘判定(中心間距離とサイズに基づく閾値)
      for (let i = 0; i < monsters.length; i++) {
        for (let j = i + 1; j < monsters.length; j++) {
          const m1 = monsters[i];
          const m2 = monsters[j];
          if (m1.type !== m2.type) {
            const m1Center = {
              x: m1.x + (m1.width || DEFAULT_MONSTER_WIDTH) / 2,
              y: m1.y + (m1.height || DEFAULT_MONSTER_HEIGHT) / 2
            };
            const m2Center = {
              x: m2.x + (m2.width || DEFAULT_MONSTER_WIDTH) / 2,
              y: m2.y + (m2.height || DEFAULT_MONSTER_HEIGHT) / 2
            };
            const dx = m1Center.x - m2Center.x;
            const dy = m1Center.y - m2Center.y;
            const dist = Math.sqrt(dx * dx + dy * dy);
            const m1Size = m1.width || DEFAULT_MONSTER_WIDTH;
            const m2Size = m2.width || DEFAULT_MONSTER_WIDTH;
            const collisionThreshold = Math.max(m1Size, m2Size) * COLLISION_FACTOR;
            if (dist < collisionThreshold) {
              m1.battle(m2, now);
            }
          }
        }
      }
      
      // HP 0 以下になったモンスターを削除
      monsters = monsters.filter(m => {
        if (m.hp <= 0) {
          gameArea.removeChild(m.container);
          return false;
        }
        return true;
      });
      
      const playersLeft = monsters.filter(m => m.type === "player").length;
      const npcsLeft = monsters.filter(m => m.type === "npc").length;
      if (playersLeft === 0 || npcsLeft === 0) {
        messageEl.textContent = (playersLeft === 0)
          ? "NPC の勝利!" : "プレイヤー の勝利!";
        return;
      } else {
        messageEl.textContent = `プレイヤー: ${playersLeft}体 NPC: ${npcsLeft}体`;
      }
      
      requestAnimationFrame(updateGame);
    }
    
    updateGame();
  </script>
</body>
</html>

2. コード解説

2.1 Monster クラス

各モンスターはこのクラスのインスタンスとして生成され、以下のような特徴やメソッドを持っています。

プロパティ

  • 種別(type)player か npc のいずれか。これにより、初期配置や戦闘対象の判定に利用。
  • 能力値
    • maxHp , hp:50~100
    • attack:5〜14
    • defense:1〜5
  • 画像 乱数で 001~250 の画像番号を選び /img/mon_###.gif というパスで画像を設定。
    • プレイヤーの場合は transform: scaleX(-1) で左右反転させます。
  • DOM 要素
    • container:モンスターコンテナの <div> 要素
    • hpBar:コンテナ内に配置される HP 表示用の <div> 要素
    • el:画像要素 <img>
  • 移動と戦闘用の変数
    • x, y:現在位置。
    • vx, vy:現在の移動速度ベクトル。
    • lastAttackTime:前回の戦闘発生時刻。これにより攻撃のクールダウンが実現されます。

メソッド

  • move(allMonsters)
    • 次の位置を vx と vy を使って計算。
    • 画面外に出ないように、また willCollideForbidden で障害物となる領域にぶつからないかを確認。
    • 問題なければ、実際の位置と DOM のスタイル (left, top) を更新します。
    • zIndex を y 座標に基づいて設定しているため、画面下にいるモンスターが前面に来るようになり、重なり順が自然に表現されます。
  • flashDamage()
    • 被ダメージ時にコンテナの背景色を一瞬赤に変更し、視覚的にダメージを示すエフェクトを行います。
  • updateHpBar()
    • 現在の HP 割合により HP バーの横幅(%)を更新。これにより、ダメージの進行が分かりやすく表示されます。
  • battle(opponent, now)
    • 戦闘処理を実行。
    • クールダウンチェック:直近の攻撃から一定時間 (ATTACK_COOLDOWN) 経過していなければ処理をスキップ。
    • ダメージ計算
      • 自分から敵へのダメージは Math.max(1, this.attack - opponent.defense) で最低ダメージ 1 を保証。
      • 同じように、敵から自分への反撃的なダメージも計算。
    • HP 更新と演出:双方の HP を更新し、flashDamage()updateHpBar() で視覚エフェクトを反映。
    • ログ出力:どちらのタイプがどの程度 HP を残しているかをコンソールに出力。
    • 弾き(リパルス)処理
      • 戦闘時、互いの中心点からのベクトル(dx, dy)を計算し、正規化して REPULSE_STRENGTH 倍することで、速やかに離れるように速度ベクトル(vx, vy)を設定します。

2.2 スポーン位置の生成 (generateSpawnPosition)

  • 目的 ゲーム開始時にモンスターが重ならないように、また一定の余白(SPAWN_MARGIN)を確保して配置するための関数です。
  • 処理内容
    • NPC とプレイヤーで初期配置の横方向の範囲を分け、NPC は画面左半分、プレイヤーは右半分に配置。
    • ランダムに位置を決め、すでに配置された位置との距離が一定以上離れているかを判定。
    • 1000回以内に問題のない位置が得られなければ、デフォルト位置を返す仕組みになっています。

3. 注意ポイント

3.1. 動的な画像サイズと初期配置の工夫

モンスター画像のサイズは、実際の画像サイズを使用している。固定値にせず、画像ごとにサイズが異なる。当たり判定や移動範囲に不整合が生じないように、以下のような工夫を施しました。

  • 画像読み込み完了後に画像サイズを取得 コンストラクタ内で、画像の onload イベントを活用して
this.width = this.el.naturalWidth;
this.height = this.el.naturalHeight;

と実際のサイズを反映。これにより、当たり判定や 出現 位置計算も正確に行えます。

  • 出現位置の算出 キャラクターが画面外に出ないよう、左右の領域を分けた上で、モンスター画像のサイズ分だけ余裕を持った位置にランダム配置しています。 spawnPositions 配列を利用して「重なり」をある程度防止する実装も取り入れています。

3.2. 分離アルゴリズムで自然な回避動作を実現

  • 周囲のモンスターとの中心間距離を計算 各モンスターの中心位置を求め、一定の閾値内にある場合に、相手との相対ベクトルを計算します。
const dx = thisCenter.x - otherCenter.x;
const dy = thisCenter.y - otherCenter.y;
const dist = Math.sqrt(dx * dx + dy * dy);
  • 反発力の加算 閾値(sepThreshold)を下回る場合は、距離が近いほど強く反発するよう、
sepForce.x += (dx / dist) * (sepThreshold - dist);
sepForce.y += (dy / dist) * (sepThreshold - dist);

と計算し、最終的にその反発ベクトルを速度(vx, vy)に加算しています。

この実装により、モンスター同士が自然な動きで互いを避けながら、追従動作も維持することができるようになりました。

3.3. 画面端でのバウンド処理

  • 座標のクランプと速度反転 例えば、水平方向については以下のように実装しています。
if (newX < 0) {
  newX = 0;
  this.vx = Math.abs(this.vx);
}
if (newX > GAME_WIDTH - currentWidth) {
  newX = GAME_WIDTH - currentWidth;
  this.vx = -Math.abs(this.vx);
}

垂直方向も同様に、端に到達したときに内向きへ速度を反転させることで、キャラクターが自然にバウンドし続け、常に動きを維持します。

3.4. ダメージ時のフラッシュ演出の改善

ダメージを受けた際に、一瞬背景色が赤くフラッシュする演出を実装します。連続してダメージを受けたときタイマーが重複してしまい、赤い状態が持続する問題も発生します。 そこで、タイマーIDの管理を行い、既存のタイマーをクリアする実装を加えます。

if (this.flashTimeout) {
  clearTimeout(this.flashTimeout);
}
this.container.style.backgroundColor = "red";
this.flashTimeout = setTimeout(() => {
  this.container.style.backgroundColor = "";
  this.flashTimeout = null;
}, 100);

これにより、確実に赤い背景がリセットされるようになります。

まとめ

今回の実装では、以下の点に注力しました。

  • 動的サイズ:画像読み込み後に実際のサイズを用いることで、当たり判定やレイアウトの整合性。
  • 分離アルゴリズム:キャラクター同士が近すぎる場合に自然に反発する力を加え、動かなくなる状況(デッドロック)を回避。
  • バウンド処理:画面端にぶつかったとき、キャラクターが内側に反射することで常に動き続ける。
  • ダメージフラッシュの改善:タイマー管理により、フラッシュ表示が正しくリセットされるように設定。

このコードをもとに、さらに独自の工夫を加えたゲーム開発にチャレンジしてみてください!