2026年4月24日金曜日

【マイクラ風開発記:第3回】ついに「壁」が出現!自作マップをふんわりジャンプで攻略だ!


皆さんこんにちは、お久しぶりです。

前回の記事では、3D空間を移動、ジャンプ、画面右下に腕を表示することに成功して悦に浸っていた私ですが、大きな問題に直面していました。

そう、この世界には「物理」がなかったのです。

地面は確かにありますが、それはただの地面。

これだけではゲームではなく、ただの散歩です。

というわけで、今回は「重力」と「当たり判定」をガッツリ実装してみました!


なお、今回から最初にコードを貼り付け、その後に解説を入れていくようにしました。


今回のコード

マイクラ風開発記 第3回
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>マイクラ風開発記 第3回</title>
    <script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/donmccurdy/aframe-extras@v7.2.0/dist/aframe-extras.min.js"></script>
  </head>
  <body>
    <a-scene>
      <a-sky color="#87CEEB"></a-sky>

      <script>
        window.onload = () => {
          const scene = document.querySelector('a-scene');
          const colors = ['#4ca64c', '#8B4513', '#808080', '#A9A9A9'];

          // 自作マップデータ
          const mapData = [
            [0,0,3,0,0,0,0,0,0,0],
            [0,1,2,0,0,0,0,3,0,0],
            [0,0,0,0,0,0,0,0,0,0],
            [0,0,0,1,1,1,1,0,0,0],
            [0,0,1,2,2,2,2,1,0,0],
            [0,0,1,2,3,3,2,1,0,0],
            [0,0,1,2,3,3,2,1,0,0],
            [0,0,1,2,2,2,2,1,0,0],
            [0,0,0,1,1,1,1,0,0,0],
            [0,0,0,0,0,0,0,0,0,0]
          ];

          const mapSize = 10;
          const offset = mapSize / 2;

          for (let z = 0; z < mapSize; z++) {
            for (let x = 0; x < mapSize; x++) {
              const height = mapData[z][x];
              // ベースの地面
              createBlock(x - offset, 0.5, z - offset, colors[0], scene);
              // 高さに応じて積み上げ
              for (let h = 1; h <= height; h++) {
                const colorIdx = Math.min(h, colors.length - 1);
                createBlock(x - offset, 0.5 + h, z - offset, colors[colorIdx], scene);
              }
            }
          }

          window.addEventListener('keydown', (e) => {
            if (e.code === 'Space') e.preventDefault();
          });
        };

        function createBlock(x, y, z, color, scene) {
          const group = document.createElement('a-entity');
          group.setAttribute('position', `${x} ${y} ${z}`);
          group.classList.add('collidable');
          const box = document.createElement('a-box');
          box.setAttribute('color', color);
          box.setAttribute('material', 'roughness: 1; metalness: 0; flatShading: true');
          const outline = document.createElement('a-box');
          outline.setAttribute('material', 'color: #000000; wireframe: true; wireframeLineWidth: 1');
          outline.setAttribute('scale', '1.002 1.002 1.002');
          group.appendChild(box);
          group.appendChild(outline);
          scene.appendChild(group);
        }
      </script>

      <a-entity id="rig" movement-controls="fly: false; speed: 0.2" position="0 1.0 4" jump-logic>
        <a-entity camera look-controls="pointerLockEnabled: true" camera="fov: 50" position="0 1.6 0">
          <a-entity id="arm-pivot" position="0.5 -0.1 -0.3">
            <a-entity id="right-hand" 
                      geometry="primitive: box; width: 0.15; height: 0.8; depth: 0.15; pivot: 0 0.5 0" 
                      material="color: #ffdbac" 
                      position="0 -0.4 0" 
                      rotation="-10 0 0"
                      animation="property: rotation; from: -10 0 0; to: -100 0 0; dur: 200; dir: alternate; loop: true; startEvents: startSwing; pauseEvents: stopSwing; enabled: true">
            </a-entity>
          </a-entity>
        </a-entity>
      </a-entity>

    </a-scene>

    <script>
      AFRAME.registerComponent('jump-logic', {
        init: function() {
          this.prevPos = new THREE.Vector3();
          this.velocity = 0;
          this.isGrounded = true;
          this.gravity = -0.006;
        },

        tick: function () {
          const el = this.el;
          let pos = el.getAttribute('position');
          const targetEls = document.querySelectorAll('.collidable');
          const targets = Array.from(targetEls).map(entry => entry.object3D);

          // 横方向の当たり判定
          let moveVec = new THREE.Vector3(pos.x - this.prevPos.x, 0, pos.z - this.prevPos.z).normalize();
          if (moveVec.length() > 0) {
            const wallRay = new THREE.Raycaster(new THREE.Vector3(pos.x, pos.y + 0.8, pos.z), moveVec, 0, 0.5);
            const wallIntersects = wallRay.intersectObjects(targets, true);
            if (wallIntersects.length > 0) {
              pos.x = this.prevPos.x;
              pos.z = this.prevPos.z;
            }
          }

          // 足元検知
          const raycaster = new THREE.Raycaster(new THREE.Vector3(pos.x, pos.y + 0.5, pos.z), new THREE.Vector3(0, -1, 0));
          const intersects = raycaster.intersectObjects(targets, true);

          let groundY = 1.0;
          let hit = false;
          if (intersects.length > 0) {
            const blockPos = intersects[0].object.el.parentElement.getAttribute('position');
            groundY = blockPos.y + 0.5;
            hit = true;
          }

          // 物理・着地
          if (!this.isGrounded || pos.y > groundY) {
            this.velocity += this.gravity;
            pos.y += this.velocity;
            this.isGrounded = false;
          }

          if (pos.y <= groundY) {
            if (hit && (groundY - this.prevPos.y <= 0.6)) {
              pos.y = groundY;
              this.velocity = 0;
              this.isGrounded = true;
            } else if (pos.y < 1.0) {
              pos.y = 1.0;
              this.velocity = 0;
              this.isGrounded = true;
            } else {
              this.isGrounded = false;
              if (hit && (groundY - this.prevPos.y > 0.6)) {
                pos.x = this.prevPos.x;
                pos.z = this.prevPos.z;
              }
            }
          }

          el.setAttribute('position', pos);
          this.prevPos.copy(pos);
        },

        startJump: function() {
          if (this.isGrounded) {
            this.velocity = 0.12;
            this.isGrounded = false;
          }
        }
      });

      const hand = document.querySelector('#right-hand');
      let isMouseDown = false;
      let swingTimeout = null;

      window.addEventListener('keydown', (e) => {
        if (e.code === 'Space') {
          const rig = document.querySelector('#rig');
          rig.components['jump-logic'].startJump();
        }
      });

      window.addEventListener('mousedown', (e) => { if (e.button === 0) { isMouseDown = true; startSwingSequence(); } });
      window.addEventListener('mouseup', (e) => { if (e.button === 0) isMouseDown = false; });

      function startSwingSequence() {
        hand.emit('startSwing');
        clearTimeout(swingTimeout);
        swingTimeout = setTimeout(() => {
          if (isMouseDown) startSwingSequence();
          else { hand.emit('stopSwing'); hand.setAttribute('rotation', '-10 0 0'); }
        }, 400);
      }
    </script>
  </body>
</html>


それではここからはコードの解説をしていきます。


1. 「目」ではなく「センサー」で世界を見る

まず取り組んだのが当たり判定です。

Raycaster

透明な「当たり判定用の矢」を放つ装置。


intersectObjects

放った矢が「どのオブジェクトに刺さったか」をリストで返してくれるメソッド。


前方センサー

「進む先に高い壁がないか?」をチェック。

高い壁があれば強制的に移動をストップさせます。

          // 横方向の当たり判定

          let moveVec = new THREE.Vector3(pos.x - this.prevPos.x, 0, pos.z - this.prevPos.z).normalize();

          if (moveVec.length() > 0) {

            const wallRay = new THREE.Raycaster(new THREE.Vector3(pos.x, pos.y + 0.8, pos.z), moveVec, 0, 0.5);

            const wallIntersects = wallRay.intersectObjects(targets, true);

            if (wallIntersects.length > 0) {

              pos.x = this.prevPos.x;

              pos.z = this.prevPos.z;

            }

          }

Gemini解説

自分が進もうとしている方向(moveVec)に短いビームを飛ばし、ブロックに当たったら「おっと、そこは壁だ!」と判断して移動を止めている


足元センサー

「下に何かあるか?」を常にチェック。

何もなければ重力で落下し、ブロックがあればピタッと着地します。

          const raycaster = new THREE.Raycaster(new THREE.Vector3(pos.x, pos.y + 0.5, pos.z), new THREE.Vector3(0, -1, 0));

          const intersects = raycaster.intersectObjects(targets, true);


これでようやく、ブロックに「ゴンッ!」とぶつかる感覚が生まれました。


Gemini解説

自分の位置から真下(0, -1, 0)に向かってビームを飛ばしている。

これで「足元にブロックがあるかな?」と常に確認しているから、階段を登ったり、崖から落ちたりといった動きができるようになる。



2. 「1.5ブロック」という絶妙な跳躍

マイクラといえば、1段の段差は登れるけど2段は登れない、あの絶妙なジャンプ力ですよね。

これを再現するために、重力と初速の数値を何度も微調整(チューニング)しました。

当初、処理が軽すぎて「超高速ピョンピョン丸」になってしまう事件が発生しましたが、重力をあえて弱めることで、「ふわっと浮いて、ストンと落ちる」心地よい滞空時間を手に入れました。


ジャンプ

スペースキーを押したときに「今から跳ぶよ!」と合図を送っている部分

// スクリプトの後半にあるイベントリスナー

window.addEventListener('keydown', (e) => {

  if (e.code === 'Space') {

    const rig = document.querySelector('#rig');

    // jump-logicコンポーネントの中にある「startJump」という関数を呼び出している

    rig.components['jump-logic'].startJump();

  }

});


呼び出される側の処理

startJump: function() {

  if (this.isGrounded) { // 地面に足がついている時だけ!

    this.velocity = 0.12; // 上向きの速度を一気に与える(これが跳躍ね!)

    this.isGrounded = false; // 「空中にいる」状態にする

  }

}


空中での動きと「重力」

跳ね上がった後は、放っておくとそのまま宇宙まで飛んでいっちゃいます。

なので毎フレーム実行される tick 関数の中で「重力」を計算しています。

// 物理・着地判定の部分

if (!this.isGrounded || pos.y > groundY) {

  this.velocity += this.gravity; // 重力(マイナスの値)を速度に足し続ける

  pos.y += this.velocity;       // 計算した速度の分だけ、高さを変える

  this.isGrounded = false;

}


Gemini解説

ここで this.gravity(-0.006)という小さな下向きの力を加え続けることで、最初は勢いよく上がっても、だんだん勢いが落ちて、最後には自由落下するように作られている。


「着地」の処理

最後は、地面に激突してめり込まないように止める処理します。

if (pos.y <= groundY) { // もし地面より低い位置(=めり込んだ)になったら

  if (hit && (groundY - this.prevPos.y <= 0.6)) {

    pos.y = groundY;    // 高さを地面ぴったりに合わせる

    this.velocity = 0;  // 落ちる勢いをゼロにする

    this.isGrounded = true; // 「着地した」状態に戻す

  }

  // ...(中略)

}


Geminiの意見

スペースキーで、上向きの「速度」をセットする。

tick(毎秒更新)の中で、常に「重力」を引いて、高さを書き換える。

地面の高さまで来たら、高さを固定して動きを止める。

この「速度(velocity)」と「加速度(gravity)」を分ける考え方は、アクションゲームの基本中の基本だから、ここを押さえておくと他のゲーム作りにも応用できるよ!


3. 数字の並びが「世界」に変わる魔法

そして今回、ジャンプでブロックを登れるようにするなら、自分でマップを作れた方が面白さが出るんじゃないかな?と思ったので、今回だけはマップを数字表記にしてみました。

(この記事の最初の画像は、下記マップの数値を高くしてみた画像です)

マップを作る

[0,0,3,0,0,0,0,0,0,0],

[0,1,2,0,0,0,0,3,0,0],

[0,0,0,0,0,0,0,0,0,0],

[0,0,0,1,1,1,1,0,0,0],

[0,0,1,2,2,2,2,1,0,0],

[0,0,1,2,3,3,2,1,0,0],

[0,0,1,2,3,3,2,1,0,0],

[0,0,1,2,2,2,2,1,0,0],

[0,0,0,1,1,1,1,0,0,0],

[0,0,0,0,0,0,0,0,0,0]

数字が意味不明に並んでますが、これがマップです。

横はそのまま横座標ブロック、縦は奥行き座標ブロックとなっており、それぞれの数字は

0は地面

1以上の数字は高さのブロックです。

高さはいくつでも行けるらしいのですが、あんまり高くすると…パソコンがやばくなるかもしれませんので、せいぜい10個とかそのくらいで様子見しながらがいいです。


マップの広さを増やしたい場合

これは簡単に増やすことが出来るのですが、上記マップをいちいち入力するのが大変になるかと思います。

もし20×20にしたい場合、上記マップの数字を増やすのと、次の場所を20に書き換える事。

const mapSize = 10;


ブロックの色を変えたい場合

下記のカラーコードを変えてください。

const colors = ['#4ca64c', '#8B4513', '#808080', '#A9A9A9'];

今回のコードで説明すると

1段目:#4ca64c

2段目:#8B4513

3段目:#808080

4段目:#A9A9A9

もっと増やしたい場合、「, '#カラーコード'」を増やせば増えていきます。


プレイヤーの位置を変更

プレイヤーのスタート地点を変えたい!という場合は下記を変えてください。

<a-entity id="rig" movement-controls="fly: false; speed: 0.2" position="0 1.0 4" jump-logic>

position="-5 1.0 -5" にすれば、マップの角(左手前)からスタート!



腕の「動き」を定義している場所

腕の「動き」を定義している場所

<a-entity id="right-hand" 

          geometry="primitive: box; width: 0.15; height: 0.8; depth: 0.15; pivot: 0 0.5 0" 

          material="color: #ffdbac" 

          position="0 -0.4 0" 

          rotation="-10 0 0"

          animation="property: rotation; from: -10 0 0; to: -100 0 0; dur: 200; dir: alternate; loop: true; startEvents: startSwing; pauseEvents: stopSwing; enabled: true">

</a-entity>


Gemini解説

ここで大事なのが animation という項目。

・from: -10 0 0; to: -100 0 0;: 角度をマイナス10度からマイナス100度まで動かす、つまり「振り下ろす」設定にしている。

・dur: 200; dir: alternate;: 0.2秒(200ミリ秒)で、行って戻ってを繰り返す(alternate)設定。

・startEvents: startSwing;: 「startSwing という合図が来たら動きなさい」という命令。


クリックに合わせて「合図」を送る場所

マウスをクリックしたときにそのアニメーションを動かしているJavaScriptの部分。

// マウスを押したとき

window.addEventListener('mousedown', (e) => { 

  if (e.button === 0) { // 左クリックなら

    isMouseDown = true; 

    startSwingSequence(); // ぶんぶんスタート!

  } 

});


function startSwingSequence() {

  hand.emit('startSwing'); // ここで「startSwing」という合図を腕に送っているわ!

  // ...(中略)...

}


Gemini解説

この hand.emit('startSwing') が実行されると、さっきHTMLで設定したアニメーションが動き出す仕組みになっている。


「連打」と「押しっぱなし」を支える仕組み

単にアニメーションを再生するだけじゃなくて、JavaScriptで「まだマウスは押されているかな?」と常にチェックしているのがこの部分。

  clearTimeout(swingTimeout); // 2. 前のタイマーをリセット(二重動作防止)


  swingTimeout = setTimeout(() => {

    if (isMouseDown) {

      startSwingSequence(); // 3. まだ押してたら、自分自身をもう一度呼ぶ(ループ!)

    } else {

      hand.emit('stopSwing'); // 4. 離してたら止める

      hand.setAttribute('rotation', '-10 0 0'); // 5. 腕を定位置に戻す

    }

  }, 400); // 0.4秒ごとに判定

}


1回クリック

isMouseDown がすぐに false になるから、1回(0.4秒分)振ってピタッと止まる。

押しっぱなし

isMouseDown が true のままだから、0.4秒ごとに「もう一回!」「もう一回!」って自分を呼び出し続けてずっとぶんぶんが続く。


ただループさせるだけだと、クリックをやめた時に腕が変な角度で止まっちゃうことがあるけれど、このコードなら最後に必ず元の位置(-10度)に戻るようになっています。



今回の解説はこの辺まで!

不具合でたまにキャラが暴走するのも、ご愛嬌。

HTMLの深淵を覗きつつ、着実に「マイクラ」に近づいています。


さて、土台は整いました。

次は…いよいよこの腕でブロックを「破壊」するというのをやろうかと思いますが、果たして出来るのかどうか…(^^;


0 件のコメント:

コメントを投稿

I'm sorry, Japanese text only.
荒らし目的と思われるコメントは気づき次第対処します。