More than 1 year has passed since last update.

今週もやってきました!週刊マイ◯ラWeb!本日の司会進行も、うーぴょん(さすらいうさぎ)が担当していきたいと思います。先週は所々の事情で休んでしまいましたが、今週から再び頑張っていこうと思います!
今週は、エンティティーを作っていきたいと思います。エンティティーは重力の影響を受けるもの、また動くものととりあえず定義しておきます!

アジェンダ的な

  1. Entityクラスの作成と最低限の実装
  2. EntityCreatureクラスの作成
  3. EntityPlayerクラスの作成

Let's tryの時間

Entityクラスの実装

全てのエンティティーの親クラスとなるEntityクラスを作っていきます。持っておきたいプロパティは以下の通りです。

プロパティ

  • world: エンティティが所属するワールド
  • x: エンティティの中心のx座標
  • y: エンティティの中心のy座標
  • z: エンティティの中心のz座標
  • nextX: エンティティの次に移動するx座標
  • nextY: エンティティの次に移動するy座標
  • nextZ: エンティティの次に移動するz座標
  • vx: エンティティのx軸方向の速度
  • vy: エンティティのy軸方向の速度
  • vz: エンティティのz軸方向の速度
  • width: エンティティの幅・奥行き
  • height: エンティティの高さ
  • gravity: 重力加速度(定数)
  • updateInterval: 更新を行う頻度
  • needToUpdate: 更新を行う必要があるか

Entityのあたり判定は、底面が正方形の直方体で考えていこうと思います(本家もそうやってるぽいので)。

js/entity/Entity.js
class Entity {
  constructor(targetWorld, posX, posY, posZ) {
    this.gravity = 9800;
    this.updateInterval = 1;
    this.needToUpdate = true;
    this.world = targetWorld;
    this.x = posX;
    this.y = posY;
    this.z = posZ;
    this.nextX = 0;
    this.nextY = 0;
    this.nextZ = 0;
    this.vx = 0;
    this.vy = 0;
    this.vz = 0;
    this.width = 0;
    this.height = 0;

    this.update();
  }

  update() {
    if(this.needToUpdate) {
      this.nextX = this.x + this.vx * this.updateInterval / 1000;
      this.nextY = this.y + this.vy * this.updateInterval / 1000;
      this.nextZ = this.z + this.vz * this.updateInterval / 1000;
      this.vy -= this.gravity * this.updateInterval / 1000;

      if(this.canMove()) {
        this.x = this.nextX;
        this.y = this.nextY;
        this.z = this.nextZ;
      }
    }
    setTimeout(() => this.update(), this.updateInterval);
  }


  canMove() {
    return true;
  }

  isOnGround() {
    return true;
  }
}
index.html
<script src="/js/blocks/Blocks.js"></script>
<script src="/js/entity/Entity.js"></script><!-- このコードを追加 -->

メソッドを説明します。コンストラクタは各種初期化を行っています。udpate()メソッドは、一定時間おきに処理したいものをまとめておきます。(重力の計算とか当たり判定とか)
canMove()メソッドは移動が可能か返すメソッドです。
canMove()メソッドの実装は後に回します。isOnGround()メソッドは、地面にエンティティが乗っかっているか返します。

EntityCreatureクラスの作成

Entityクラスを継承してEntityCreatureクラスを作成します。こちらは生き物を想定したメソッドになります。

以下のプロパティをさらに追加します。

  • yaw: ヨー
  • pitch: ピッチ
  • sightX: 視線ベクトルのx成分
  • sightY: 視線ベクトルのy成分
  • sightZ: 視線ベクトルのz成分
js/entity/EntityCreature.js
class EntityCreature extends Entity {
  constructor(targetWorld, posX, posY, posZ) {
    super(targetWorld, posX, posY, posZ);
    this.pitch = 0;
    this.yaw = 0
  }

  walk(direction) {
    const radian = Math.PI / 180;
    let moveX = 50 * Math.cos(radian * this.yaw), moveZ = 50 * Math.sin(radian * this.yaw);

    if(direction == 0) {
      this.vx = moveX;
      this.vz = moveZ;
    } else if(direction == 1) {
      this.vx = -moveZ;
      this.vz = moveX;
    } else if(direction == 2) {
      this.vx = -moveX;
      this.vz = -moveZ;
    } else if(direction == 3) {
      this.vx = moveZ;
      this.vz = -moveX;
    }
  }

  stop() {
    this.vx = 0;
    this.vz = 0;
  }

  jump() {
    if(this.isOnGround()) {
      this.vy = 10;
    }
  }

  fly(direction) {
    if(direction == 0) {
      this.y += 0.3;
    } else if(direction == 1) {
      this.y -= 0.3;
    }
  }

  rotate(deltaYaw, deltaPitch) {
    const radian = Math.PI / 180;
    this.yaw += deltaYaw;
    this.pitch -= deltaPitch;
    if(this.pitch > 90) {
      this.pitch = 90;
    } else if(this.pitch < -90) {
      this.pitch = -90;
    }
    this.sightX = Math.cos(radian * this.pitch) * Math.cos(radian * this.yaw);
    this.sightY = Math.sin(radian * this.pitch);
    this.sightZ = Math.cos(radian * this.pitch) * Math.sin(radian * this.yaw);
  }

  isOnGround() {
    return true;
  }

  getEyeSight() {
    return [this.sightX, this.sightY, this.sightZ];
  }
}
index.html
<script src="/js/blocks/Entity.js"></script>
<script src="/js/entity/EntityCreature.js"></script><!-- このコードを追加 -->

メソッドも幾つか追加していますが、だいたい名前の通りなので割愛します。

EntityPlayerクラスの作成

EntityCreatureクラスを継承します。新たに次のプロパティを追加します。

  • target: 攻撃・破壊の対象。

STEP1 EntityPlayerクラスを作る

js/entity/EntityPlayer.js
class EntityPlayer extends EntityCreature {
  constructor(targetWorld, posX, posY, posZ) {
    super(targetWorld, posX, posY, posZ);
    this.width = 0.6;
    this.height = 1.7;
    this.target = null;
  }

  update() {
    let getTargetBlockPos = (targetX, targetY, targetZ) => {
      let getPenetratingPosOnBlock = (blockPosX, blockPosY, blockPosZ) => {
        let result = [];
        let getPositionWhen = (axis, v) => {
          let t = 0;
          switch(axis) {
            case 0:
              t = (v - this.x) / this.sightX;
              return [v, this.y + 0.75 + t * this.sightY, this.z + t * this.sightZ];
            case 1:
              t = (v - this.y - 0.75) / this.sightY;
              return [this.x + t * this.sightX, v, this.z + t * this.sightZ];
            case 2:
              t = (v - this.z) / this.sightZ;
              return [this.x + t * this.sightX, this.y + 0.75 + t * this.sightY, v];
            default:
              return null;
          }
        };
        result.push(getPositionWhen(1, blockPosY + 0.5));
        result.push(getPositionWhen(0, blockPosX + 0.5));
        result.push(getPositionWhen(2, blockPosZ + 0.5));
        result.push(getPositionWhen(0, blockPosX - 0.5));
        result.push(getPositionWhen(2, blockPosZ - 0.5));
        result.push(getPositionWhen(1, blockPosY - 0.5));

        return result.map(pos => (pos[0] >= blockPosX - 0.5 && pos[0] <= blockPosX + 0.5 && pos[1] >= blockPosY - 0.5  && pos[1] <= blockPosY + 0.5 && pos[2] >= blockPosZ - 0.5 && pos[2] <= blockPosZ + 0.5) ? pos : null);
      };    
      let pos = getPenetratingPosOnBlock(targetX, targetY, targetZ);
      let getDistance = (a, b) => Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2));

      if(targetX < 0 || targetY < 0 || targetZ < 0) {
        return null;
      }

      if(getDistance([this.x, this.y + 0.75, this.z], [targetX, targetY, targetZ]) > 10) {
        return null;
      }

      if(this.world.getBlock(targetX, targetY, targetZ) != Blocks.air) {
        let side = null;
        if(pos[0] != null && this.sightY < 0) {
          side = 0;
        } else if(pos[1] != null && this.sightX < 0) {
          side = 1;
        } else if(pos[2] != null && this.sightZ < 0) {
          side = 2;
        } else if(pos[3] != null && this.sightX > 0) {
          side = 3;
        } else if(pos[4] != null && this.sightZ > 0) {
          side = 4;
        } else if(pos[5] != null && this.sightY > 0) {
          side = 5;
        }
        return [[targetX, targetY, targetZ], side];
      }

      if(pos[0] != null && this.sightY > 0) {
        return getTargetBlockPos(targetX, targetY + 1, targetZ);
      } else if(pos[1] != null && this.sightX > 0) {
        return getTargetBlockPos(targetX + 1, targetY, targetZ);
      } else if(pos[2] != null && this.sightZ > 0) {
        return getTargetBlockPos(targetX, targetY, targetZ + 1);
      } else if(pos[3] != null && this.sightX < 0) {
        return getTargetBlockPos(targetX - 1, targetY, targetZ);
      } else if(pos[4] != null && this.sightZ < 0) {
        return getTargetBlockPos(targetX, targetY, targetZ - 1);
      } else if(pos[5] != null && this.sightY < 0) {
        return getTargetBlockPos(targetX, targetY - 1, targetZ);
      }

      return null;
    }

    this.target = getTargetBlockPos(Math.floor(this.x), Math.floor(this.y + 0.75), Math.floor(this.z));
    super.update();
  } 

  getEyePos() {
    return [this.posX, this.posY + 0.75, this.posZ];
  }


  destroyBlock() {
    if(this.target == null) {
      return null;
    }

    let blockPos = this.target[0];
    this.world.setBlock(blockPos[0], blockPos[1], blockPos[2], Blocks.air);
    return blockPos;
  }

  putBlock() {
    if(this.target == null) {
      return null;
    }
    let pos = [this.target[0][0], this.target[0][1], this.target[0][2]];
    let side = this.target[1];
    switch(side) {
      case 0:
        pos[1] += 1;
        break;
      case 1:
        pos[0] += 1;
        break;
      case 2:
        pos[2] += 1;
        break;
      case 3:
        pos[0] -= 1;
        break;
      case 4:
        pos[2] -= 1;
        break;
      case 5:
        pos[1] -= 1;
        break;
    }
    this.world.setBlock(pos[0], pos[1], pos[2], Blocks.dirt);
    return pos;
  }
}
index.html
<script src="/js/blocks/EntityCreature.js"></script>
<script src="/js/entity/EntityPlayer.js"></script><!-- このコードを追加 -->

STEP2 今までのコードの書き換え

Entityクラス達ができて一部処理が移ったので、諸々書き換えます。

js/Rendering.js
class Rendering {
  constructor(canvasId, targetWorld, targetPlayer) {
    this.renderer = new THREE.WebGLRenderer({antialias: true});
    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(40, 800 / 480);
    this.renderer.setSize(800, 480);
    this.renderer.setClearColor(0xffffff);
    this.meshes = [];
    this.world = targetWorld;
    this.player = targetPlayer;
    document.getElementById(canvasId).appendChild(this.renderer.domElement);
    this.init();
    this.update();
  } 

  init() {
    let light = new THREE.AmbientLight(0xffffff);
    this.cube = new THREE.BoxGeometry(1, 1, 1);
    this.scene.add(light);
    this.resetWorld();
  }

  resetWorld() {
    for(let x = 0; x < 16; ++x) {
      this.meshes[x] = [];
      for(let z = 0; z < 16; ++z) {
        this.meshes[x][z] = [];
        for(let y = 0; y < 256; ++y) {
          let block = new THREE.Mesh(this.cube, this.world.getBlock(x, y, z).getMaterial());

          block.position.set(x, y, z);
          this.meshes[x][z][y] = block;
          this.scene.add(block);
        }
      }
    }
  }

  update() {
    let update = () => {
      let [cameraX, cameraY, cameraZ] = this.player.getEyeSight();
      requestAnimationFrame(update);
      this.camera.position.set(this.player.x, this.player.y + 0.75, this.player.z);
      this.camera.lookAt(new THREE.Vector3(this.player.x + cameraX, this.player.y + cameraY + 0.75, this.player.z + cameraZ));
      if(this.player.target != null) {
        let info = this.player.target;
        let pos = (info != null) ? info[0] : null;
        let side = (info != null) ? info[1] : null;
        this.showSelectedBlockMarker(pos[0], pos[1], pos[2]);
      } else {
        this.removeSelectedBlockMarker();
      }
      this.renderer.render(this.scene, this.camera);
    };
    update();
  }

  updateMesh(x, y, z) {
    let block = new THREE.Mesh(this.cube, this.world.getBlock(x, y, z).getMaterial());

    block.position.set(x, y, z);
    this.scene.remove(this.meshes[x][z][y]);
    this.scene.add(block);
    this.meshes[x][z][y] = block;
  }

  showSelectedBlockMarker(x, y, z) {
    if(this.selectedBlockMarker != undefined) {
      this.scene.remove(this.selectedBlockMarker);
    }

    this.selectedBlockMarker = new THREE.EdgesHelper(this.meshes[x][z][y], 0x000000);
    this.scene.add(this.selectedBlockMarker);
  }

  removeSelectedBlockMarker() {
    if(this.selectedBlockMarker != undefined) {
      this.scene.remove(this.selectedBlockMarker);
    }
  }
}
js/application.js
window.onload = () => {
  let world = new World();
  let player = new EntityPlayer(world, 0, 64.85, 0);
  //デバッグの時は入れておくと良い
  //player.gravity = 0;
  //player.vy = 0
  let rendering = new Rendering("canvas_wrapper", world, player);
  let mouseButtons = 0;
  let destroyOrPutBlock = mode => {
    if(mode == 1) {
      let blockPos = player.destroyBlock();
      if(blockPos != null) {
        rendering.updateMesh(blockPos[0], blockPos[1], blockPos[2]);
      } 
    } else if(mode == 2) {
      let blockPos = player.putBlock();
      if(blockPos != null) {
        rendering.updateMesh(blockPos[0], blockPos[1], blockPos[2]);
      } 
    }
  }

  document.onkeydown = (e) => {
    if(e.keyCode == 87) {
      player.walk(0);
    } else if(e.keyCode == 68) {
      player.walk(1);
    } else if(e.keyCode == 83) {
      player.walk(2);
    } else if(e.keyCode == 65) {
      player.walk(3);
    }
    if(e.keyCode == 32 && e.shiftKey) {
      player.fly(1);
    } else if(e.keyCode == 32) {
      player.fly(0);
    }
    destroyOrPutBlock(mouseButtons);
  }

  document.onkeyup = (e) => {
    if(e.keyCode == 87 || e.keyCode == 68 || e.keyCode == 83 || e.keyCode == 65) {
      player.stop();
    }
  }

  document.onmousemove = (e) => {
    let deltaX = e.momentX || e.webkitMovementX || e.mozMovementX || e.movementX;
    let deltaY = e.momentY || e.webkitMovementY || e.mozMovementY || e.movementY;

    player.rotate(deltaX / 0.8, deltaY / 0.8);
    destroyOrPutBlock(mouseButtons);
  }

  document.onmousedown = (e) => {
    mouseButtons = e.buttons;
    destroyOrPutBlock(mouseButtons);
  }

  document.oncontextmenu = () => false;

  document.onmouseup = () => {
    mouseButtons = 0;
  }
}

処理がまとまったおかげでかなりスッキリしましたね!特記事項はないので説明を割愛します!
重力加速度が入っている影響でプレイヤーが下に落ちてしまうので、デバッグする時はプロパティを上書きします!

あとがき

かなり大規模に書き換えを行なったので、バグが非常に出てきて大変でした。しかし、重要なコードを使い回せるようにできたので、ここから一気に進められると思います!
当たり判定まで頑張りたかったのですが、これだけでいっぱいだったので今週はここまで!

第6週はこちらから

週刊 WebでMinec◯aft的なものを作る! ~第6週目~

第8回はこちらから

WebでMinec◯aft的なものを作る! ~第8回目~