以前、HSP3オフィシャル3D素材の珠音ちゃんを走ったり止まったり方向転換するだけのサンプルを作成しました。
ThreeJSでHSP3オフィシャル3D素材「珠音」を走らせてみる
今回はゲームとして遊べるようにします。
珠音(たまね)は、Hot Soup Processor(HSP)とともに使用することのできる 3Dサンプル素材です。HSP3の3D描画ライブラリであるHGIMG3/HGIMG4により表示可能なデータとスクリプト、及び元データがすべて収められています。
ここからダウンロードできます。
Contents
HTML部分
スマホでも遊べるようにcanvasのサイズは320×480ピクセルとし、ボタンはcanvasの上に配置します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>走れ!珠音ちゃん</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> #container { position: relative; } #left { position: absolute; left: 10px; top: 400px; width: 90px; height: 50px; background-color: transparent; font-weight: bold; color: #ffffff; } #jump { position: absolute; left: 110px; top: 400px; width: 100px; height: 50px; background-color: transparent; font-weight: bold; color: #ffffff; } #right { position: absolute; left: 220px; top: 400px; width: 90px; height: 50px; background-color: transparent; font-weight: bold; color: #ffffff; } #start { position: absolute; left: 90px; top: 340px; width: 140px; height: 50px; background-color: transparent; font-weight: bold; color: #ffffff; } #result { position: absolute; top: 30px; width: 320px; text-align: center; color: #ffffff; font-size: 20px; } .yellow { color: yellow; } .red { color: red; } </style> </head> <body> <div id = "container"> <canvas id="canvas"></canvas> <button id = "start">START</button> <button id = "left">LEFT</button> <button id = "right">RIGHT</button> <button id = "jump">JUMP</button> <div id = "result">aa</div> </div> <div id = "debug"></div> <script src="https://unpkg.com/three@0.140.2/build/three.min.js"></script> <script src="https://unpkg.com/three@0.137.4/examples/js/loaders/GLTFLoader.js"></script> <script src="./index.js"></script> </body> </html> |
JavaScript部分
主なグローバル変数を示します。
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const $left = document.getElementById('left'); const $right = document.getElementById('right'); const $jump = document.getElementById('jump'); const $start = document.getElementById('start'); const $result = document.getElementById('result'); const $good = new Audio('./good.mp3'); const $bad = new Audio('./bad.mp3'); const $finish1 = new Audio('./finish1.mp3'); const $finish2 = new Audio('./finish2.mp3'); let isPlaying = false; // 現在プレイ中か? |
ボタンの操作に対応させる
先にプレイヤーを左右に移動する処理を示します。
Left関数とRight関数はグローバル変数のisLeftとisRightを変更するだけです。更新処理時にフラグの状態に応じてプレイヤーの移動処理がおこなわれます。
1 2 3 4 5 6 7 8 9 10 11 12 |
let isLeft = false; let isRight = false; function Left(value, ev){ ev.preventDefault(); isLeft = value; } function Right(value, ev){ ev.preventDefault(); isRight = value; } |
ボタンが押されたらプレイヤーを左右に移動できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function InitButtons(){ $left.addEventListener('touchstart', (ev) => Left(true, ev)); $right.addEventListener('touchstart', (ev) => Right(true, ev)); $left.addEventListener('touchend', (ev) => Left(false, ev)); $right.addEventListener('touchend', (ev) => Right(false, ev)); $jump.addEventListener('click', (ev) => Jump()); $start.addEventListener('click', (ev) => Start()); // ロングタッチでコンテキストメニューが表示されないようにする document.oncontextmenu = () => { return false; }; $left.addEventListener('mousedown', (ev) => Left(true, ev)); $right.addEventListener('mousedown', (ev) => Right(true, ev)); $left.addEventListener('mouseup', (ev) => Left(false, ev)); $right.addEventListener('mouseup', (ev) => Right(false, ev)); document.addEventListener('mouseup', () => { isLeft = false; isRight = false; }); $result.style.display = 'none'; } |
3Dオブジェクトの追加
シーンの生成とシーンへの3Dオブジェクトの追加の処理を示します。座標を(0,0,0)にセットすると足の地面に接している部分が(0,0,0)に来るようになっているようです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
const scene = new THREE.Scene(); function AddTamane(){ // GLTF形式のモデルデータを読み込む const loader = new THREE.GLTFLoader(); const buildingUrl = "./high_school_girl.glb"; loader.load(buildingUrl, (gltf) => LoadedGltf(gltf)); // 第二引数はコールバック関数 } let tamane_gltf = null; let mixer; function LoadedGltf(gltf){ tamane_gltf = gltf; const model = gltf.scene; model.scale.set(1.0, 1.0, 1.0); model.position.set(0, 0, 0); scene.add(model); const animations = gltf.animations; if(animations && animations.length) { mixer = new THREE.AnimationMixer(model); let animation = animations[0]; let action = mixer.clipAction(animation) ; action.play(); } } |
シーンに床を追加する処理を示します。
1 2 3 4 5 6 7 8 9 10 |
let floor = null; function AddFloor(){ const geometry = new THREE.PlaneGeometry( 4000, 8000, 4); const material = new THREE.MeshBasicMaterial( {color: 0x008800, side: THREE.DoubleSide} ); const plane = new THREE.Mesh( geometry, material ); plane.rotation.x = Math.PI / 2; scene.add( plane ); floor = plane; } |
障害物である円柱を生成します。X座標が0だと中心で左にいくと大きくなります。Z座標は奥にいくたびに小さくなります。円柱は左右に移動しますが、第三引数で最初は左に移動するのかどうかが決まります。
まとめて動かせるように生成した3Dオブジェクトは配列に格納しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
const cylinderGeometry = new THREE.CylinderGeometry( 50, 50, 300, 32 ); const cylinderMaterial = new THREE.MeshStandardMaterial({color: 0x008888, roughness:0.5}); function AddCylinder(x, z, isLeft){ const cylinder = new THREE.Mesh( cylinderGeometry, cylinderMaterial); scene.add( cylinder ); cylinder.position.y = 150; // 底面が床に接するように浮かせる cylinder.position.x = x; cylinder.position.z = z; cylinder.isLeft = isLeft; cylinder.isMiss = false; return cylinder; } let cylinders = []; function AddCylinders(){ let z = 0; for(let i = 1; i <= 8; i++){ z += -700; let x = Math.floor(Math.random() * 100 - 50); cylinders.push(AddCylinder(x, z, i % 2 == 0)); } for(let i = 1; i <= 8; i++){ z += -500; let x = Math.floor(Math.random() * 100 - 50); cylinders.push(AddCylinder(x, z, i % 2 == 0)); } } |
シーンのところどころに壁を追加します。壁はジャンプで乗り越えます。当たってしまうとミスになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
const wallGeometry = new THREE.BoxGeometry( 1000, 100, 50 ); const wallMaterial = new THREE.MeshStandardMaterial({color: 0xcccccc, roughness:0.5}); function AddWall(z){ const cube = new THREE.Mesh(wallGeometry, wallMaterial); cube.position.y = 50; // 底面が床に接するように浮かせる cube.position.z = z; cube.isMiss = false; scene.add(cube); return cube; } let walls = []; function AddWalls(){ walls.push(AddWall(-2000)); walls.push(AddWall(-4000)); walls.push(AddWall(-6000)); walls.push(AddWall(-8000)); walls.push(AddWall(-10000)); } |
ページが読み込まれたときの処理
ページの読み込みを待ってレンダラー、カメラ、障害物の生成をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
let camera = null; let renderer = null; const initCameraX = 0; const initCameraY = 300; const initCameraZ = 700; // ページの読み込みを待つ window.addEventListener('DOMContentLoaded', Init); function Init() { InitButtons(); // レンダラーを作成 renderer = new THREE.WebGLRenderer({ canvas: document.querySelector("#canvas") }); // 幅、高さ let width = 320; let height = 480; renderer.setPixelRatio(1); renderer.setSize(width, height); // カメラを作成 camera = new THREE.PerspectiveCamera(45, width / height, 1, 10000); camera.position.set(initCameraX, initCameraY, initCameraZ); camera.lookAt(new THREE.Vector3(0, 250, 0)); // 平行光源 const light = new THREE.DirectionalLight(0xffffff); light.intensity = 2; // 光の強さを倍に light.position.set(0, 0, 1); scene.add(light); AddTamane(); // プレイヤーをシーンに追加する AddFloor(); // 床をシーンに追加する AddCylinders(); // 障害物をシーンに追加する AddWalls(); // 壁をシーンに追加する renderer.gammaOutput = true; renderer.gammaFactor = 2.2; Tick(); } |
プレイヤーをシーンに追加したら更新処理でアニメーションさせます。mixer.updateの引数は普通はclock.getDelta()でよいのですが、走っているように見えないので値を調整しています。
1 2 3 4 5 6 7 8 9 |
const clock = new THREE.Clock(); function Tick() { renderer.render(scene, camera); requestAnimationFrame(Tick); if(mixer) mixer.update(clock.getDelta() * 1.4); } |
アニメーションに関する処理
プレイヤーに走る動作をさせる処理を示します。
1 2 3 4 5 6 7 8 9 |
function Run(){ if(tamane_gltf == null) return; const model = tamane_gltf.scene; mixer = new THREE.AnimationMixer(model); let action = mixer.clipAction(tamane_gltf.animations[2]) ; action.play(); } |
これは走る動作を止めます。走っている状態からいきなり停止するのは違和感があるので250ミリ秒歩く動作をさせてから停止させています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function Stop(){ const model = tamane_gltf.scene; mixer = new THREE.AnimationMixer(model); let action = mixer.clipAction(tamane_gltf.animations[1]) ; action.clampWhenFinished = true; action.play(); setTimeout(()=>{ mixer = new THREE.AnimationMixer(model); let action = mixer.clipAction(tamane_gltf.animations[0]) ; action.clampWhenFinished = true; action.play(); }, 250); } |
ジャンプする動作の処理を示します。40回の更新で1サイクルとし、前半の20回で上昇して後半の20回で下降します。ジャンプ中に新たにジャンプの処理が開始されないようにフラグで制御しています。また飛びながら走るのはおかしいのでジャンプ時は停止時の動作にしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
let jumping = false; function Jump(){ if(tamane_gltf == null || jumping || !isPlaying) return; const model = tamane_gltf.scene; jumping = true; Stop(); for(let i = 0; i < 40; i++){ if(i<20){ setTimeout(()=>{ model.position.y += 10; }, 33 * i); } else { setTimeout(()=>{ model.position.y -= 10; }, 33 * i); } } setTimeout(()=>{ Run(); jumping = false; model.position.y = 0; }, 33 * 40); } |
キー操作でも動作するようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
document.onkeydown = (e) =>{ if(!isPlaying) return; if(e.key == 'ArrowRight'){ isRight = true; } if(e.key == 'ArrowLeft'){ isLeft = true; } if(e.key == 'ArrowUp'){ Run(); } if(e.key == 'ArrowDown'){ Stop(true); } if(e.key == ' '){ Jump(); } } document.onkeyup = (e) => { if(e.key == 'ArrowRight'){ isRight = false; } if(e.key == 'ArrowLeft'){ isLeft = false; } } |
更新処理
更新処理を示します。33ミリ秒ごとにUpdate関数が呼び出されます。
プレイヤーのZ座標が-10500以下になったらゲーム終了です。このときFinish関数が呼び出されますが、二重に呼び出されないように isPlaying == true のときだけ呼び出しています。
isLeftとisRightフラグをみて左右に移動する動作を実現しています。また障害物も左右に移動させます。もしプレイヤーが円柱や壁と接触したときは、Miss関数を呼び出してミス時の処理をおこないます。またプレイヤーに衝突しなかった障害物は視認性が悪くなるだけなのでシーンから取り除いています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
setInterval(()=> Update(), 33); function Update(){ if(tamane_gltf == null) return; const model = tamane_gltf.scene; if(model.position.z <= -10500){ if(isPlaying) Finish(); return; } if(isPlaying && tamane_gltf.scene != null){ // カメラ、床、プレイヤーを奥に向けて移動させる model.position.z -= 16; camera.position.z -= 16; floor.position.z -= 16; if(isLeft && model.position.x > -128) model.position.x -= 8; if(isRight && model.position.x < 128) model.position.x += 8; // プレイヤーより手前に来た障害物をシーンから取り除く // このときisMissプロパティがfalseなら回避は成功している // 円柱のチェック let arr = cylinders.filter(_=> _.position.z > model.position.z + 4); if(arr.length > 0){ if(!arr[0].isMiss){ Good(); } scene.remove(arr[0]); } cylinders = cylinders.filter(_=> _.position.z <= model.position.z + 4); // 壁のチェック arr = walls.filter(_=> _.position.z > model.position.z + 4); if(arr.length > 0){ if(!arr[0].isMiss){ Good(); } scene.remove(arr[0]); } walls = walls.filter(_=> _.position.z <= model.position.z + 4); // 当たり判定(配列が空でなければミス) arr = cylinders.filter(cylinder => IsCollisionCylinder(cylinder)); if(arr.length > 0){ Miss(); } // 当たり判定(配列が空でなければミス) arr = walls.filter(wall => IsCollisionWall(wall)); if(arr.length > 0){ Miss(); } } // 円柱を左右に移動させる for(let i = 0; i < cylinders.length; i++){ if(cylinders[i].isLeft == true) cylinders[i].position.x -= 4; if(cylinders[i].isLeft == false) cylinders[i].position.x += 4; if(cylinders[i].position.x <= -128) cylinders[i].isLeft = false; if(cylinders[i].position.x >= 128) cylinders[i].isLeft = true; } } |
当たり判定
円柱とプレイヤーの当たり判定をする処理を示します。接触している場合は3Dオブジェクトに新たに追加したisMissプロパティをtrueにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function IsCollisionCylinder(cylinder){ if(tamane_gltf == null || cylinder.isMiss) return false; const model = tamane_gltf.scene; let x1 = model.position.x; let z1 = model.position.z; let x2 = cylinder.position.x; let z2 = cylinder.position.z; if(Math.pow(x1- x2, 2) + Math.pow(z1- z2, 2) < Math.pow(100, 2)){ cylinder.isMiss = true; return true; } else return false; } |
壁とプレイヤーの当たり判定をする処理を示します。接触している場合は3Dオブジェクトに新たに追加したisMissプロパティをtrueにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function IsCollisionWall(wall){ if(tamane_gltf == null || wall.isMiss) return false; const model = tamane_gltf.scene; let z = model.position.z; let y = model.position.y; if(Math.abs(z - wall.position.z) < 25 && y < 50 ){ wall.isMiss = true; return true; } else return false; } |
回避成功回数とミスの回数を数えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let goodCount = 0; function Good(){ goodCount++; $good.currentTime = 0; $good.play(); } let badCount = 0; function Miss(){ badCount++; $bad.currentTime = 0; $bad.play(); } |
ゲーム終了時の処理
ゲーム終了時の処理を示します。
背中を向けて走っていた珠音ちゃんに正面を向かせます。それと同時にドラムロールの音を鳴らして回避成功回数とミスの回数を表示して終了です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
function Finish(){ isPlaying = false; Stop(); // 背中を向けて走っていた珠音ちゃんに正面を向かせる const model = tamane_gltf.scene; for(let i=0; i < 16; i++){ setTimeout(() => { model.rotation.y = Math.PI * (1 - i / 16); }, 33 * i); } setTimeout(() => { model.rotation.y = 0; }, 33 * 16); // ドラムロールの音を鳴らす setTimeout(()=>{ $finish1.currentTime = 0; $finish1.play(); }, 500); setTimeout(()=>{ $finish1.pause(); $finish2.currentTime = 0; $finish2.play(); $start.style.display = 'block'; $result.style.display = 'block'; $result.innerHTML = `<span class = "yellow">成功 ${goodCount}</span><br><span class = "red">失敗 ${badCount}</span>`; }, 3000); } |
ゲーム開始時の処理
ゲームをスタートする処理を示します。回避成功回数とミス回数をカウントする変数をリセットし、結果を表示する要素を非表示にします。もう一度ゲームをするときはプレイヤーとカメラと床のZ座標を元の位置に戻します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
function Start(){ goodCount = 0; badCount = 0; $result.style.display = 'none'; const model = tamane_gltf.scene; if(model.position.z < 0) Retry(); $start.style.display = 'none'; // 正面を向いている珠音ちゃんを走る方向に向かせる for(let i=0; i < 16; i++){ setTimeout(() => { model.rotation.y = Math.PI * (i / 16); }, 33 * i); } setTimeout(() => { model.rotation.y = Math.PI; isPlaying = true; Run(); }, 33 * 16); } function Retry(){ const model = tamane_gltf.scene; model.position.z = 0; model.position.x = 0; camera.position.z = initCameraZ; floor.position.z = 0; cylinders.forEach(cylinder => scene.remove(cylinder)); cylinders = []; walls.forEach(wall => scene.remove(wall)); walls = []; AddCylinders(); AddWalls(); } |