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

  • 2
    いいね
  • 0
    コメント

だいぶ前回の記事から時間が経ってしまいましたね!今回も大学生プログラマのうーぴょん(さすらいうさぎ)がお送りしていきます。

すでにお気づきの方もいるかもしれませんが、今回から週刊というワードが抜けたのと、週目から回目に変更となりました。うーぴょんがなかなか時間を取れなくなってきたからです。
それでも諦めることなく頑張っていくのでよろしくお願いします!

今回は、イベントからイベントリスナーへの書き換えと、エンティティとブロックのあたり判定に挑戦していきます。

アジェンダ!

  1. application.jsの構造変更!
  2. あたり判定をつけよう!

Let's try!

application.jsの構造変更!

application.jsが無意味に入れ子構造になっていたので改良しました。またonloadやonclickと違ってaddEventListenerでイベントハンドラを書くと上書きされないので、ついでに書き換えます。

js/application.js
let world = new World();
let player = new EntityPlayer(world, 0, 64.85, 0);
let rendering = new Rendering(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]);
    } 
  }
};

player.gravity = 0;
player.vy = 0

window.addEventListener("keydown", (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);
});

window.addEventListener("keyup", (e) => {
  if(e.keyCode == 87 || e.keyCode == 68 || e.keyCode == 83 || e.keyCode == 65) {
    player.stop();
  }
});

window.addEventListener("mousemove", (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);
});

window.addEventListener("mousedown", (e) => {
  mouseButtons = e.buttons;
  destroyOrPutBlock(mouseButtons);
});

window.addEventListener("mouseup", () => {
  mouseButtons = 0;
});

window.addEventListener("load", () => {
  rendering.init("canvas_wrapper");
});

window.oncontextmenu = () => false;

合わせて、js/rendering.jsも書き換えます。

js/rendering.js(抜粋)
class Rendering {
  constructor(targetWorld, targetPlayer) {
    let light = new THREE.AmbientLight(0xffffff);

    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;
    this.cube = new THREE.BoxGeometry(1, 1, 1);
    this.scene.add(light);
    this.update();
  } 

  init(canvasId) {
    document.getElementById(canvasId).appendChild(this.renderer.domElement);
    this.resetWorld();
  }

あたり判定をつけよう

ブロックは直方体(立方体)です。エンティティも直方体とみなしましょう!
そうすると、この2つの立体の中心間の距離と、立体の中心から出て行くベクトルのうち、その立体から脱出するまでの距離(中心からベクトルが立体表面を通過する点までの距離)の和を比較することで、衝突しているか比較することができます。

STEP1 Vector3Dクラスの作成

とりあえずベクトルをいじりそうなので、3次元ベクトルのクラスを用意します。

js/math/Vector3D.js
class Vector3D {
  constructor(p, q, r) {
    this.x = p;
    this.y = q;
    this.z = r;
  }

  add(v) {
    return new Vector3D(this.x + v.x, this.y + v.y, this.z + v.z);
  }

  sub(v) {
    return new Vector3D(this.x - v.x, this.y - v.y, this.z - v.z);
  }

  mul(k) {
    return new Vector3D(k * this.x, k * this.y, k * this.z);
  }

  div(k) {
    return new Vector3D(this.x / k, this.y / k, this.z / k);
  }

  len() {
    return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2) + Math.pow(this.z, 2));
  }

  getDistanceTo(v) {
    return Math.sqrt(Math.pow(this.x - v.x, 2) + Math.pow(this.y - v.y, 2) + Math.pow(this.z - v.z));
  }

  innerProduct(v) {
    return this.x * v.x + this.y * v.y + this.z * v.z;
  }

  copy() {
    return new Vector3D(this.x, this.y, this.z);
  }
}

コンストラクタの引数は実数を想定しています。
ベクトル同士の計算のときに本体をオーバーライドしないように、新しくオブジェクトを作成しています。

htmlにも読み込ませます。

index.html
  <script src="/js/three.min.js"></script>
  <script src="/js/math/Vector3D.js"></script><!-- これを追加 -->
  <script src="/js/blocks/Block.js"></script>

STEP2 CubeModelクラスの作成

あたり判定を行いやすくするために直方体のモデルがあると便利です。

js/math/CubeModel.js
class CubeModel {
  constructor(center, width, depth, height) {
    this.x = center.x;
    this.y = center.y;
    this.z = center.z;
    this.w = width;
    this.d = depth;
    this.h = height;
  }

  getTopPenetratingPoint(o, p) {
    let y1 = this.y + this.h / 2;
    let k = (y1 - o.y) / p.y;
    let v = p.mul(k).add(o);
    v.y = y1;

    return (k >= 0 && Math.abs(v.x - this.x) <= this.w / 2 && Math.abs(v.z - this.z) <= this.d / 2) ? v : null;
  }

  getNorthPenetratingPoint(o, p) {
    let x1 = this.x + this.w / 2;
    let k = (x1 - o.x) / p.x;
    let v = p.mul(k).add(o);
    v.x = x1;

    return (k >= 0 && Math.abs(v.y - this.y) <= this.h / 2 && Math.abs(v.z - this.z) <= this.d / 2) ? v : null;
  }

  getWestPenetratingPoint(o, p) {
    let z1 = this.z + this.d / 2;
    let k = (z1 - o.z) / p.z;
    let v = p.mul(k).add(o);
    v.z = z1;

    return (k >= 0 && Math.abs(v.x - this.x) <= this.w / 2 && Math.abs(v.y - this.y) <= this.h / 2) ? v : null;
  }

  getSouthPenetratingPoint(o, p) {
    let x1 = this.x - this.w / 2;
    let k = (x1 - o.x) / p.x;
    let v = p.mul(k).add(o);
    v.x = x1;

    return (k >= 0 && Math.abs(v.y - this.y) <= this.h / 2 && Math.abs(v.z - this.z) <= this.d / 2) ? v : null;
  }

  getEastPenetratingPoint(o, p) {
    let z1 = this.z - this.d / 2;
    let k = (z1 - o.z) / p.z;
    let v = p.mul(k).add(o);
    v.z = z1;

    return (k >=0 && Math.abs(v.x - this.x) <= this.w / 2 && Math.abs(v.y - this.y) <= this.h / 2) ? v : null;
  }

  getBottomPenetratingPoint(o, p) {
    let y1 = this.y - this.h / 2;
    let k = (y1 - o.y) / p.y;
    let v = p.mul(k).add(o);
    v.y = y1;

    return (k >= 0 && Math.abs(v.x - this.x) <= this.w / 2 && Math.abs(v.z - this.z) <= this.d / 2) ? v : null;
  }

  getPenetratingPoints(o, p) {
    return [ 
      this.getTopPenetratingPoint(o, p),
      this.getNorthPenetratingPoint(o, p),
      this.getWestPenetratingPoint(o, p),
      this.getSouthPenetratingPoint(o, p),
      this.getEastPenetratingPoint(o, p),
      this.getBottomPenetratingPoint(o, p)
    ];
  }

  doseCollide(b) {
    let c1 = new Vector3D(this.x, this.y, this.z);
    let c2 = new Vector3D(b.x, b.y, b.z);
    let p = c2.sub(c1);
    let penetratingPoint1 = this.getPenetratingPoints(c1, p).filter(e => e != null)[0];
    let penetratingPoint2 = b.getPenetratingPoints(c2, p.mul(-1)).filter(e => e != null)[0];
    if(penetratingPoint1 == null || penetratingPoint2 == null) {
      return true;
    }
    let p1 = penetratingPoint1.sub(c1);
    let p2 = penetratingPoint2.sub(c2);

    return p.len() < p1.len() + p2.len();
  }
}

doseCollideメソッドに先のアルゴリズムで衝突判定を実装しました。
get~Pointメソッドは、始点ベクトルと方向ベクトルを受け取って面との交点を返します。面と交わらなければnullが返ります。各メソッドでk >= 0を条件に入れたのは、ベクトルが反対を向くのを防ぐためです。
面はy軸方向にtop、x軸方向にNorth、z軸方向にWestという感じで名付けました。またx軸方向の広がりをw、y軸方向をh、z軸方向をdとしてあります。

index.html
  <script src="/js/three.min.js"></script>
  <script src="/js/math/Vector3D.js"></script>
  <script src="/js/math/CubeModel.js"></script><!-- 追加 -->

STEP3 エンティティとブロックがあたり判定の範囲を返すようにする。

js/blocks/Block.js(抜粋)
  getBoundaryBox(x, y, z) {
    return new CubeModel(new Vector3D(x, y, z), 1, 1, 1);
  }
js/entity/Entity.js(抜粋)
  getBoundaryBox() {
    return new CubeModel(new Vector3D(this.x, this.y, this.z), this.width, this.width, this.height);
  }

STEP4 判定する

あたり判定を計算しなければならないブロックはエンティティが次にいる座標を中心に(エンティティの直方体モデルの底面の対角線の長さ / 2) × (エンティティの直方体モデルの底面の対角線の長さ / 2) × (エンティティの直方体モデルの底面の対角線の長さ / 2)の範囲のブロックとします。

js/entity/Entity.js(抜粋)
  update() {                                                                             
    if(this.needToUpdate) {                                                              
      this.nextX = this.x + this.vx * this.updateInterval / 1000;                        
      this.nextY = (this.isOnGround()) ? this.y : 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;                                                             
      } else {                                                                           
        this.vx = 0;
        this.vy = 0;
        this.vz = 0;                                                                     
      }
    }
    setTimeout(() => this.update(), this.updateInterval);                                
  } 

  canMove() {
    let diag = Math.sqrt(2) * this.width / 2;                                            
    let entityModel = this.getBoundaryBox();                                             

    for(let x = Math.round(this.nextX - diag); x < Math.round(this.nextX + diag); ++x) { 
      for(let z = Math.round(this.nextZ - diag); z < Math.round(this.nextZ + diag); ++z) {          
        for(let y = Math.round(this.nextY - this.height / 2); y < Math.round(this.nextY + this.height / 2); ++y) {                                                                 
          let block = this.world.getBlock(x, y, z);                                      
          if(block != null && block != Blocks.air && entityModel.doseCollide(block.getBoundaryBox(x, y, z))) {                                                                     
            return false;
          }                                                                              
        }                                                                                
      }
    }
    return true;
  }                                                                                      

  isOnGround() {
    let block = this.world.getBlock(Math.round(this.x), Math.round(this.y - this.height / 2) - 1, Math.round(this.z));
    return block != null && block != Blocks.air;
  }
js/World.js(抜粋)
  getBlock(x, y, z) {
    try {
      return this.blocksAroundPlayer[x][z][y];
    } catch(e) {
      return null;
    }
  }
js/entity/EntityCreater.js
  /* いらないので削除 */
  isOnGround() {
    return true;
  }
js/application.js
let player = new EntityPlayer(world, 0, 65.35, 0);//y座標を変更

//以下2つがあれば削除
player.gravity = 0;
player.vy = 0;

多少バグがありますが一旦OKとしましょう!

座標変換のミス

three.jsの座標が物体の中心だと気付いたのが割りと後だった影響で、一部座標の変換のミスがミスっているので修正します。切り捨てから四捨五入にすれば大丈夫です。

js/entity/EntityPlayer.js(抜粋)
this.target = getTargetBlockPos(Math.round(this.x), Math.round(this.y + 0.75), Math.round(this.z));
    super.update();
  }

意外とこれだけでした。

まとめ

一見あたり判定は大変そうって思っていましたが、直方体2つの距離関係で衝突してるか否かが判別できると気付いたのでなんとか実装できました。こう見ると結構シンプル!
せっかく便利なクラスを作ったので、それを使った書き換えを次回行おうと思います!

第7回はこちらから