Edited at
phina.jsDay 15

phina.js で月と地球をつくろう!

この記事は phina.js Advent Calendar の15日目です!

前(12/14)phina.jsで画像を変換して新しく登録する by @YukiYukiVirtual さん


はじめに

今年も様々なニュースが取り上げられましたが、皆さんはどんなニュースが印象に残っているでしょうか?

私は小惑星探査機の「はやぶさ2」がついに小惑星のリュウグウに到着し、探査を始めたというニュースが印象に残っています。日本や世界から集められた様々な技術やアイデアが詰め込まれたこの探査機がどのような成果をあげるのか、はやぶさ2の帰還が予定されている2020年が楽しみですね!

さて、そんな宇宙に思いを馳せながら、今回は phina.js を使って惑星と衛星のシミュレーションをしてみたいと思います。はやぶさ2も、太陽系内の様々な惑星からの重力を綿密にシミュレーションしてどのようなルートでリュウグウまで行くのか計画を立てていますが、今回はそれに似たようなことをしてみます。


月と地球の基本編

さて、月と地球のシミュレーションと聞いても、いまいち実感がわかない方や、「難しそう...」と思った方も多いのではないでしょうか? でも、心配は不要です。簡単です。

月と地球の動きのシミュレーションは今年の秋頃に放送されたTV番組の「【ちちんぷいぷいプログラミング】#6:月と地球の重力をプログラミングしてみる!?」でとてもわかり易く説明されています。(YouTube で見れます)

このTV番組では MoonBlock というやつが使われていますが、あんまり使いやすくないので phina.js で書き直してみました。

Screenshot from 2018-12-16 11-30-53.png


コード

Demo(RunStant)

phina.globalize();

const SCREEN_WIDTH = 960;
const SCREEN_HEIGHT = 960;

// 万有引力定数
const GRAVITATION = -0.02;

phina.define('MainScene', {
superClass: 'DisplayScene',
init() {
this.superInit({
backgroundColor: 'black',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT
});

const earth = Earth({
x: SCREEN_WIDTH / 2,
y: SCREEN_HEIGHT / 2,
fill: 'blue',
radius: 60
}).addChildTo(this);

const markerGroup = DisplayElement().addChildTo(this);

for (let i = 400; i <= 600; i += 10) {

const moon = Moon(earth, i / 1000, 0, {
x: SCREEN_WIDTH / 2,
y: SCREEN_HEIGHT / 2 - 100,
radius: 10,
fill: 'yellow'
}).addChildTo(this);

Marker(moon, 40, {stroke: 'orange', strokeWidth: 10}).addChildTo(markerGroup);
}

}
});

phina.define('Earth', {
superClass: 'CircleShape',
init(obj) {
this.superInit(obj);
this.mass = 1000;
}
});

phina.define('Moon', {
superClass: 'CircleShape',
init(earth, vx, vy, obj) {
this.superInit(obj);
// 初速度
this.vx = vx;
this.vy = vy;
// 質量
this.mass = 0.00001;
// 地球を指定
this.earth = earth;
},
update() {
// 自分(月)と地球の x, y 方向の差をとる。
const dx = this.x - this.earth.x;
const dy = this.y - this.earth.y;

// 自分と地球の距離の2乗を求める。
const R2 = dx**2 + dy**2;

// 引力F[N]を求める。
// 万有引力の方程式
//
// G[m^3/(kg x s^2)] x M[kg] x m[kg]
// F[N] = -------------------------------------
// (R[m])^2
//
const F = GRAVITATION * this.earth.mass * this.mass / R2;

// 自分と地球の距離
const R = Math.sqrt(R2);

// Fをx, y 方向に分解
const Fx = F * dx / R;
const Fy = F * dy / R;

// x, y 方向の加速度を求める
// 運動方程式
//
// F[N] = m[kg] x a[m/s^2]
//
const ax = Fx / this.mass;
const ay = Fy / this.mass;

// 初速度に加速度をたす
this.vx += ax;
this.vy += ay;

// 移動させる
this.x += this.vx;
this.y += this.vy;

// 画面から出た時と地球にぶつかった時に消す
if (this.x < 0 || SCREEN_WIDTH < this.x || this.y < 0 || SCREEN_HEIGHT < this.y) {
this.remove();
} else if (R < this.radius + this.earth.radius) {
this.remove();
}
}
});

phina.define('Marker', {
superClass: 'PathShape',
init(target, frame, obj) {
this.superInit(obj);
this.target = target;
this.updateFrame = frame;
},
update(app) {
if (app.frame % this.updateFrame === 0) {
this.addPath(this.target.x, this.target.y);
}
if (!this.target.parent) {
this.remove();
}
}
});

phina.main(() => {
const app = GameApp({
startLabel: 'main',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
fps: 120
});
app.run();
});

Demo(RunStant)

コードの説明はコメントでいろいろ書いたので今更する必要はないかもしれませんが、少し説明します。

なお、phina.js の基本的な部分については説明していませんので、ご了承ください。


説明

まず、地球のクラス Earth と月のクラス Moon 用意しました。それぞれ、mass(質量)のプロパティがあります。また、月の方には、vx(x軸方向の初速度)、vy(y軸方向の初速度)というプロパティがあります。

月は更新されたら、まず自分と地球の間の引力の大きさを求めます。

ここで役に立つのが万有引力の方程式です。万有引力定数、地球の質量、月の質量、地球と月の距離、の4つの物理量から自分と地球の間の引力の大きさを求められます。

万有引力定数は -0.02 にしていますが、この例だとこのぐらいがいい感じです。

// 自分(月)と地球の x, y 方向の差をとる。

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

// 自分と地球の距離の2乗を求める。
const R2 = dx**2 + dy**2;

// 引力F[N]を求める。
// 万有引力の方程式
//
// G[m^3/(kg x s^2)] x M[kg] x m[kg]
// F[N] = -------------------------------------
// (R[m])^2
//
const F = GRAVITATION * this.earth.mass * this.mass / R2;

次に、求めた力を x, y 方向に分解して加速度を求めます。

// Fをx, y 方向に分解

const Fx = F * dx / R;
const Fy = F * dy / R;

// x, y 方向の加速度を求める
// 運動方程式
//
// F[N] = m[kg] x a[m/s^2]
//
const ax = Fx / this.mass;
const ay = Fy / this.mass;

// 初速度に加速度をたす
this.vx += ax;
this.vy += ay;

最後に、初速度と加速度を合成して、月の位置に反映させます。また、地球に落ちてしまったときや画面外に出てしまったときは自分をremoveして更新対象から外します。

// 移動させる

this.x += this.vx;
this.y += this.vy;

// 画面から出た時と地球にぶつかった時に消す
if (this.x < 0 || SCREEN_WIDTH < this.x || this.y < 0 || SCREEN_HEIGHT < this.y) {
this.remove();
} else if (R < this.radius + this.earth.radius) {
this.remove();
}

やったー!これで、毎フレームごとに月が移動するようになりました。

でも、これだけでは月の動きがわかりづらいので、マーカーをつけてみます。下記のコードで、Marker クラスを定義しました。

phina.define('Marker', {

superClass: 'PathShape',
init(target, frame, obj) {
this.superInit(obj);
this.target = target;
this.updateFrame = frame;
},
update(app) {
if (app.frame % this.updateFrame === 0) {
this.addPath(this.target.x, this.target.y);
}
if (!this.target.parent) {
this.remove();
}
}
});

Marker クラスのコンストラクタは第一引数にターゲットとなる物体を、第二引数に更新頻度を、第三引数にその他のオプションをとります。

ターゲットが remove されると、自分自身も remove します。

更新頻度は、1フレームごとだと重くなるので、この例では40フレームごとにしました。


Vector2を使ってもっとシンプルに書く

基本編ではいちいち力を x, y 方向に分解して...みたいな面倒くさいことをしましたが、速度や加速度、力といった物理量はベクトルで表すことができます。phina.js でも、ベクトルを簡単に扱うことができる Vector2 クラスが提供されていて、それらを使うと先程のコードもよりシンプルに書くことができます。ちなみに、Object2D クラスやそれを継承したクラスは position プロパティで Vector2 のインスタンスを持っており、x プロパティや y プロパティは position.x プロパティと position.y プロパティのアクセッサになっています。


コード

Demo(RunStant)

phina.globalize();

const SCREEN_WIDTH = 960;
const SCREEN_HEIGHT = 960;

// 万有引力定数
const GRAVITATION = -0.02;

phina.define('MainScene', {
superClass: 'DisplayScene',
init() {
this.superInit({
backgroundColor: 'black',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT
});

const earth = Earth({
x: SCREEN_WIDTH / 2,
y: SCREEN_HEIGHT / 2,
fill: 'blue',
radius: 60
}).addChildTo(this);

const markerGroup = DisplayElement().addChildTo(this);

for (let i = 400; i <= 600; i += 10) {

const moon = Moon(earth, Vector2(i / 1000, 0), {
x: SCREEN_WIDTH / 2,
y: SCREEN_HEIGHT / 2 - 100,
radius: 10,
fill: 'yellow'
}).addChildTo(this);

Marker(moon, 40, {stroke: 'orange', strokeWidth: 10}).addChildTo(markerGroup);
}

}
});

phina.define('Earth', {
superClass: 'CircleShape',
init(obj) {
this.superInit(obj);
this.mass = 1000;
}
});

phina.define('Moon', {
superClass: 'CircleShape',
init(earth, v, obj) {
this.superInit(obj);
// 初速度
this.v = v;
// 質量
this.mass = 0.00001;
// 地球を指定
this.earth = earth;
},
update() {
// 自分(月)と地球の座標の差をとる。
const d = Vector2.sub(this.position, this.earth.position);

// 自分と地球の距離の2乗を求める。
const R2 = d.lengthSquared();

// 自分と地球の距離
const R = d.length();

// 引力F[N]を求める。
// 万有引力の方程式
//
// G[m^3/(kg x s^2)] x M[kg] x m[kg]
// F[N] = -------------------------------------
// (R[m])^2
//
// →
// → G[m^3/(kg x s^2)] x M[kg] x m[kg] R
// F[N] = ------------------------------------- x -----
// (R[m])^2 R
//
// → →
// R = d であることに注意
//
const F = Vector2.mul(d, GRAVITATION * this.earth.mass * this.mass / R2 / R);

// 加速度を求める
// 運動方程式
// → →
// F[N] = m[kg] x a[m/s^2]
//
const a = Vector2.div(F, this.mass);

// 初速度に加速度をたす
this.v.add(a);

// 移動させる
this.position.add(this.v);

// 画面から出た時と地球にぶつかった時に消す
if (this.x < 0 || SCREEN_WIDTH < this.x || this.y < 0 || SCREEN_HEIGHT < this.y) {
this.remove();
} else if (R < this.radius + this.earth.radius) {
this.remove();
}
}
});

phina.define('Marker', {
superClass: 'PathShape',
init(target, frame, obj) {
this.superInit(obj);
this.target = target;
this.updateFrame = frame;
},
update(app) {
if (app.frame % this.updateFrame === 0) {
this.addPath(this.target.x, this.target.y);
}
if (!this.target.parent) {
this.remove();
}
}
});

phina.main(() => {
const app = GameApp({
startLabel: 'main',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
fps: 120
});
app.run();
});

Demo(RunStant)


変更点をちょっと説明

まず、Moon クラスのコンストラクタは第二引数で初速度に Vector2 のインスタンスをとるようになりました。

更新された時の処理にも変更が加わっています。

更新されると、最初に、月の位置ベクトルから地球の位置ベクトルを引きます。すると、地球から月に向かうベクトル d が得られます。

Vector2 クラスには静的メソッドの sub があり、このメソッドは第一引数のベクトルから第二引数のベクトルを引いたベクトルを新たに生成して返します。

// 自分(月)と地球の座標の差をとる。

const d = Vector2.sub(this.position, this.earth.position);

続いて、先程得たベクトルの大きさと大きさの2乗を求めます。

// 自分と地球の距離の2乗を求める。

const R2 = d.lengthSquared();

// 自分と地球の距離
const R = d.length();

次に、月に加わる引力のベクトルを求めます。万有引力の方程式で得られるのは引力の大きさなので、先程得た d ベクトルの単位ベクトルをかけて、月に加わる引力のベクトル F を得ます。

Vector2 クラスには、静的メソッドの mul があり、第一引数のベクトルに第二引数のスカラーをかけて得た新しいベクトルを生成して返します。

// 引力F[N]を求める。

// 万有引力の方程式
//
// G[m^3/(kg x s^2)] x M[kg] x m[kg]
// F[N] = -------------------------------------
// (R[m])^2
//
// →
// → G[m^3/(kg x s^2)] x M[kg] x m[kg] R
// F[N] = ------------------------------------- x -----
// (R[m])^2 R
//
// → →
// R = d であることに注意
//
const F = Vector2.mul(d, GRAVITATION * this.earth.mass * this.mass / R2 / R);

そして、Fと自分の質量から加速度を求めます。これもベクトルで求められます。先程のようにx, y 方向に力を分解したりしないで済むのでラクです。(やっていることは同じですが...)

Vector2には静的メソッドの div があり第一引数のベクトルを第二引数のスカラーで割って得た新しいベクトルを生成して返します。

// 加速度を求める

// 運動方程式
// → →
// F[N] = m[kg] x a[m/s^2]
//
const a = Vector2.div(F, this.mass);

初速度に加速度をたします。

Vector2 のインスタンスメソッドである add を使うことによって、引数で与えたベクトルを足すことができます。

// 初速度に加速度をたす

this.v.add(a);

最後に移動させ、地球に落ちてしまったときや画面外に出てしまったときは自分をremoveして更新対象から外します。

// 移動させる

this.position.add(this.v);

// 画面から出た時と地球にぶつかった時に消す
if (this.x < 0 || SCREEN_WIDTH < this.x || this.y < 0 || SCREEN_HEIGHT < this.y) {
this.remove();
} else if (R < this.radius + this.earth.radius) {
this.remove();
}

こんな感じで、Vector2を使ってシンプルに(?)重力シミュレーションを書くことができました。

ゲーム制作は座標を扱うので、Vector2 はおおいに役立つと思います。


最後に

以上、Vector2クラスの機能の紹介が中心になりましたが、この記事はこれで終わりにします。

本当はもっと様々なシミュレーションの例を載せたかったのですが、どれもうまくいかず、結局これだけになってしまいました。

本当は太陽と地球と月がうまくうごいているサンプルをお見せしたかったのですが、パラメータをいくらいじってもうまく行かないので試行錯誤中です。

重力シミュレーションをゲームに取り入れるのは難しそうですが、学校教育の現場でもICT化が進んでいるみたいなので、物理の時間にこういうプログラムを見せてくれる先生がいたりすると面白いのではないかなと思いました。(Algodooで十分かw)

あんまり phina.js との接点が少ない記事になってしまいすみません...

↓試行錯誤中の画像

ダウンロード.png