【Wonderfl移植計画】yawarakaBalls 解説編【1作目】
wonderflのFavoriteTop100にランクインしている作品をCreateJSを使って移植しようという計画。
ライセンスはMITなのを確認していますが、制作者様の方から何かアクションがあった場合は削除させていただきます。
86位 YawarakaBalls
移植元のFlashソースコード
【CreateJSのデモ】
【jsdo.itのページ】
前回はソースコードを載せただけなので、今回はその解説を行いたいと思います。
変形の解説
まず、何故このようにボールが変形するのかについて解説。
まず変形の正体からネタばらし。
以下のコードでスケールを調整してボールを潰した(変形させた)後に回転処理を加えているからです。
// render関数内 // 衝突していた場合 if(this.priority > 0) { /** * ここではボールの衝突によるベクトルを求め、 * ベクトルのスカラ値からボールを横方向に潰して、 * ベクトルの向きに回転させることで * あたかもボールが力を受けて潰れたような表現を実現している */ // 衝突してズレた中心点の角度を求める var angle = Math.atan2(this.cy, this.cx); // どれだけのズレたか(力を受けたか)のスカラ値を求める var length = Math.sqrt(this.cx * this.cx + this.cy * this.cy); // [縦方向は適当] // スカラ値からスケールを横方向は縮小、縦方向に拡大させる this.matrix.scale(length / this.radius, 1 + (1 - length / this.radius)); // 衝突してズレた方向にボールを回転させる this.matrix.rotate(angle); }
ここでcy, cxという2つのフィールドが現れたので、この値がどのように変化するのかに注目してみる。
まず、コンストラクタ上で以下のようにフィールドを定義し、0で初期化している。
// 変形のための接触位置 this.cx = 0; this.cy = 0;
次にcx, cyの値が変化する部分に注目してみる。
接触点の絶対座標の更新 contact()の解説
// [接触点の絶対座標を指定] /** * 接触した後にどのような力を加えるかを決める関数 * @param {Number} contatX 接触したX座標 * @param {Number} contatY 接触したY座標 * @param {int} priority 接触した時の優先度 * @param {Number} force 反射係数 */ Ball.prototype.contact = function(contactX, contactY, priority, force) { // 接触した点からベクトルを求める var tx = contactX - this.x; var ty = contactY - this.y; // ベクトルの向きと大きさを計算 var angle = Math.atan2(ty, tx); var length = Math.sqrt(tx * tx + ty * ty); // ベクトルの向きと大きさから衝突した方向に力(加速度)を加える this.vx -= (1.0 - length / this.radius) * force * Math.cos(angle); this.vy -= (1.0 - length / this.radius) * force * Math.sin(angle); // [変形のための接触点は1つしか使用できない。床を優先させる] // 優先度は以下の様な関係になっている // 未接触(0) < 壁(1) < ボール同士の接触(2) < 床(3) // 床との接触があった後に、ボールとの接触が発生しても // 床に対する変形が適応される if(this.priority < priority) { this.priority = priority; this.cx = tx; this.cy = ty; } }
上記の関数では接触した時に力を加える部分と、どの方向にボールを変形させる(潰す)かの処理がある。
ここで、より優先度が高い接触物との衝突が起きた場合、cx, cyフィールドの値を書き換え、
衝突が発生した向きに対して、ボールが変形するようにさせている。
次にこの関数が主にどこで呼ばれているのだろうか?
答えは二箇所で、update()とhitTest()という2つの関数内で呼ばれている。
まずは、update関数から解説していく。
毎フレームの更新処理 update()の解説
以下のコードはtick()関数内で毎フレーム4回呼び出される関数である。
主に位置・速度の更新、静止物体との衝突判定を行っている。
/** * 以下のような機能を持っている * :位置の更新 * :速度の更新 * :壁、床との接触判定 */ Ball.prototype.update = function() { // 接触点の位置情報と優先度を初期化 this.cx = 0; this.cy = 0; this.priority = 0; // 速度で位置を更新 this.x += this.vx; this.y += this.vy; // 画面左端に接触している場合 if(this.x - this.radius < 0) { this.contact(0, this.y, 1, 0.125); } // 画面右端に接触している場合 if(this.x + this.radius > SCREEN_WIDTH) { this.contact(SCREEN_WIDTH, this.y, 1, 0.125); } // 床に接触している場合 if(this.y + this.radius > FLOOR_Y) { this.contact(this.x, FLOOR_Y, 3, 0.125); } // [床に接触していなければ] // ここで非常に小さな重力を与えることで、ふんわりとした動きを実現している else { this.vy += 0.005; } // [すごい勢いで壁にぶつかったときは即座に反射] /** * @note * 反射処理(速度ベクトルの反転)をした場合は、 * ボールの位置情報も壁に沿わせるように更新するのがセオリーだと考えていたが、 * このコードではそれをせずとも壁の境目で反射し続ける現象が発生しないので、 * 不思議に思っている */ if(this.x < 0 || this.x > SCREEN_WIDTH) { this.vx *= -1; } // こちらも同様の処理 if(this.y > FLOOR_Y) { this.vy *= -1; } // [画面外のボールをこっそり減速して、全体の動きを永続化] // おそらくボール同士の接触回数を増やしたいための減速だと思われる if(this.y < -this.radius) { this.vx *= 0.9996; this.vy *= 0.9996; } }
製作者がふわふわとした動きや動作を永続化させるための微妙なパラメータ調整を行った努力がコードからも伺える。
contact()関数は床・壁との接触時に呼び出される。
個人的には反射処理で位置更新をせずとも、動作がバグらないのが不思議だった。
自分自身がコードをしっかり理解してないからだと思われるが…
ボール同士の衝突判定 hitTest()の解説
FlashではAS2からのおなじみの衝突判定関数hitTest()を冠した関数。
ボール同士の距離と半径から衝突しているかを判定し、衝突している場合はcontact()関数を呼び出す。
/** * 他のボールとの衝突を検出する関数 * @param {Ball} ball 接触判定をする対象 */ Ball.prototype.hitTest = function(ball) { // ボール同士の距離を求める var dx = ball.x - this.x; var dy = ball.y - this.y; // 距離の平方を算出 var distanceSquared = dx * dx + dy * dy; // ボールが接触するまでの距離 // 自分の半径と相手の半径を足しあせた値 var contactDistance = this.radius + ball.radius; // ボールが接触するまでの距離より、 // ボール同士の距離の平方が小さかった場合は衝突 // (ルートを使ってないのは計算の高速化が目的) if(distanceSquared < contactDistance * contactDistance) { // 接触点を求める var tx = linearTransform(this.radius, 0, contactDistance, this.x, ball.x); var ty = linearTransform(this.radius, 0, contactDistance, this.y, ball.y); // 自分と相手に衝突処理を行う // 0.2と壁や床の接触よりも大きな値にしているのは、 // ボール同士の接触による動きを大きくしたい為 this.contact(tx, ty, 2, 0.2); ball.contact(tx, ty, 2, 0.2); } }
linearTransform()で行っている処理はボールの半径を接触距離とボールの距離でスケール変換しています。
最後に、最初にも触れたボールの変形や影の描画を行っている。
描画処理 render()関数の解説
序盤に一部触れたが描画に関するrender関数についても解説する
render関数はtick()関数内で毎フレーム呼ばれる関数である。
こちらも影の処理の部分で細かくパラメータを調整していた努力が伺える。
ここで読者によってはひとつ疑問が出ると思う人もいると思うのだが、
何故、ボール本体(body)は行列で位置を更新しているのに対して、
floorShadow(影)は直接フィールドの位置を更新しているのかについて。
これはボール本体は必ず行列変換される為、並行移動を加えても計算速度が変わらないため、
フィールドによる位置の更新を行わなかったのだと思います。
/** * ボールの変形や影の描画を行う関数 */ Ball.prototype.render = function() { // 行列の初期化 this.matrix.identity(); // 衝突していた場合 if(this.priority > 0) { /** * ここではボールの衝突によるベクトルを求め、 * ベクトルのスカラ値からボールを横方向に潰して、 * ベクトルの向きに回転させることで * あたかもボールが力を受けて潰れたような表現を実現している */ // 衝突してズレた中心点の角度を求める var angle = Math.atan2(this.cy, this.cx); // どれだけのズレたか(力を受けたか)のスカラ値を求める var length = Math.sqrt(this.cx * this.cx + this.cy * this.cy); // [縦方向は適当] // スカラ値からスケールを横方向は縮小、縦方向に拡大させる this.matrix.scale(length / this.radius, 1 + (1 - length / this.radius)); // 衝突してズレた方向にボールを回転させる this.matrix.rotate(angle); } // ボールの座標に移動 this.matrix.translate(this.x, this.y); // 行列をボール本体に適当(移動、変形、回転) this.matrix.decompose(this.body); // 影の描画処理 // ボールの高さから影の透明度を算出する var height = FLOOR_Y - (this.y + this.radius); // 影の位置を床に設定する this.floorShadow.x = this.x; // ここで0.3という係数を与えることで高さによって影が微妙に上下した動きをしてるように見える this.floorShadow.y = FLOOR_Y + height * 0.3; // 透明度のスケール変換する this.floorShadow.alpha = linearTransform(height, 0, 300, 0.3, 0); // ボールの高さから影の大きさを変更する var scale = 1.2 + height * 0.005; // CreateJSにはwidthとheightというフィールドはShapeクラスには用意されていない // そのため、Blur処理にのみ使うフィールドとなっている // (現在はそのBLur処理も無効化しているので完全に無意味) this.floorShadow.width = this.radius * 2 * scale; this.floorShadow.height = this.radius * 2 * scale * 0.2; // 元のFlashのコードにはない処理 // width, heightの更新による大きさの変更が出来ないため、 // scaleX, scaleYの大きさを変更することで対処 this.floorShadow.scaleX = scale; this.floorShadow.scaleY = scale * 0.2; /** * canvasではBlur処理が重く、FPSが極端に落ちるため、 * 今回のコードでは無効化した. */ /* var blur = linearTransform(height, 0, 200, 3, 25); var blurFilter = new createjs.BlurFilter(blur, blur / 3, 1); this.floorShadow.filters = [blurFilter]; var bounds = blurFilter.getBounds(); this.floorShadow.cache( -this.floorShadow.width * 2 + bounds.x, -this.floorShadow.height * 2 + bounds.y, this.floorShadow.width * 4 + bounds.width, this.floorShadow.height * 4 + bounds.height); */ }
最後に
最後までこの記事を読んで頂きありがとうございました。
今回は初めての移植でしたが、やはり見る人に分かるように動作を理解し説明するのは大変ですね。
次の移植は果たして次の執筆日である3日で終わるのだろうか…