アクセルとブレーキは別のキーにしてほしいというリクエストがあったので別バージョンも作成しました。Zキー:アクセル、Xキー:ブレーキです。⇒ 動作確認はこちらから
前回はコースの描画をしました。今回は車の操作とコースアウトしたかどうかの判定、コースアウト時の処理をおこないます。
コースアウトしたかどうかは現在の車の座標を調べればわかります。コースを描画するときにIsCarInside関数を作りました。車の座標を調べて四捨五入で整数に変換してこの関数に渡せばコースアウトしていればfalseが返されます。
Contents
ライバル車の動きを実装する
まずはライバル車の動きから考えます。
ライバル車を管理するためのRivalCarクラスを作成します。
コンストラクタを示します。引数は生成時に存在する座標と速度です。静的変数 Carsに追加してあとでまとめて移動させます。静的関数 Addは生成されたインスタンスを配列 Carsに追加するための関数です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class RivalCar { static Cars: RivalCar[] = []; Speed: number = 0; Car: THREE.Group = null; constructor(car: THREE.Group, initX: number, initZ: number, initSpeed: number) { this.Car = car; car.position.x = initX * expansionRate; car.position.z = initZ * expansionRate; this.Speed = initSpeed * expansionRate; let angle = GetIdealRotationY(car); // 後述 car.rotation.y = angle; } static Add(rivalCar: RivalCar) { RivalCar.Cars.push(rivalCar); } } |
車の向きを最適化する
車を生成したら適切な向きにしなければなりませんが、これをするのがGetIdealRotationY関数です。車が存在するうえで理想の方向を求めるには車の座標にもっとも近いコースの両脇の2点を求め、これと直交する回転角になるような値を求めます。
1 2 3 4 5 |
function GetIdealRotationY(car: THREE.Group): number { let pts = GetNeerestBorderPoints(new Point(car.position.x, car.position.z)); let rad = Math.atan2(pts[1].Y - pts[0].Y, pts[1].X - pts[0].X); return -rad + Math.PI; } |
GetNeerestBorderPoints関数は車の座標にもっとも近いコースの両脇の2点を求めます。
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 |
function GetNeerestBorderPoints(point: Point): Point[] { let minDistanceSquared = 10000000; let ret1: Point = null; border1.forEach(pt => { let d = Math.pow(point.X - pt.X, 2) + Math.pow(point.Y - pt.Y, 2); if (minDistanceSquared > d) { minDistanceSquared = d; ret1 = pt; } }); minDistanceSquared = 100000000; let ret2 = null; border2.forEach(pt => { let d = Math.pow(point.X - pt.X, 2) + Math.pow(point.Y - pt.Y, 2); if (minDistanceSquared > d) { minDistanceSquared = d; ret2 = pt; } }); let ret = []; ret.push(ret1); ret.push(ret2); return ret; } |
次に車を動かす関数を考えます。車を理想の方向に向かせたら、センターラインからみてどちら側に寄っているかを調べます。大きく左寄りであるときは右にハンドルを切り、右寄りのときは左にハンドルを切ります。そのあと三角関数をつかってposition.xとposition.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 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 |
class RivalCar { // RemainingNumberUntilResurrectionが0より大きい場合は車はクラッシュしてスピンしている RemainingNumberUntilResurrection: number = 0; Move() { // クラッシュしてスピンしている場合の処理 if (this.RemainingNumberUntilResurrection > 0) { this.RemainingNumberUntilResurrection--; this.Car.rotation.y += Math.PI / 8; // スピン状態からの復帰 コースの中央に配置する if (this.RemainingNumberUntilResurrection <= 0) { let point = GetIdealPosition(this.Car); this.Car.position.x = point.X; this.Car.position.z = point.Y; } return; } let rad = GetIdealRotationY(this.Car); this.Car.rotation.y = rad; // どちら側に寄っているのか? let pts = GetNeerestBorderPoints(new Point(this.Car.position.x, this.Car.position.z)); let dx1 = this.Car.position.x - pts[0].X; let dz1 = this.Car.position.z - pts[0].Y; let distance1 = Math.sqrt(Math.pow(dx1, 2) + Math.pow(dz1, 2)); let dx2 = this.Car.position.x - pts[1].X; let dz2 = this.Car.position.z - pts[1].Y; let distance2 = Math.sqrt(Math.pow(dx2, 2) + Math.pow(dz2, 2)); // 左寄りなので右にハンドルを切る if (distance1 < distance2 && (distance2 - distance1) / expansionRate > 3) { if (this.Speed > 0) this.Car.rotation.y -= 1 * Math.PI / 180; } // 右寄りなので左にハンドルを切る else if (distance1 > distance2 && (distance1 - distance2) / expansionRate > 3) { if (this.Speed > 0) this.Car.rotation.y += 1 * Math.PI / 180; } this.Car.position.x += this.Speed * Math.sin(this.Car.rotation.y); this.Car.position.z += this.Speed * Math.cos(this.Car.rotation.y); // 自車との当たり判定 if (remainingNumberUntilResurrection == 0) { let d = Math.pow(car1.position.x - this.Car.position.x, 2) + Math.pow(car1.position.z - this.Car.position.z, 2); if (d < 4) { this.RemainingNumberUntilResurrection = 120; // 2秒間スピンさせる Crash(); // 自車クラッシュの処理 return; } } // ライバル車同士の当たり判定 let cars = RivalCar.Cars.filter(car => car != this); for (let i = 0; i < cars.length; i++) { // ライバル車同士の当たり判定は一方がスピンしている場合は行なわない if (this.RemainingNumberUntilResurrection > 0) continue; if (cars[i].RemainingNumberUntilResurrection > 0) continue; let d = Math.pow(cars[i].Car.position.x - this.Car.position.x, 2) + Math.pow(cars[i].Car.position.z - this.Car.position.z, 2); if (d < 4) { this.RemainingNumberUntilResurrection = 120; // 2秒間スピンさせる return; } } } } |
GetIdealPosition関数は車を配置する理想の座標を求めます。
1 2 3 4 |
function GetIdealPosition(car: THREE.Group): Point { let pts = GetNeerestBorderPoints(new Point(car.position.x, car.position.z)); return new Point((pts[1].X + pts[0].X) / 2, (pts[1].Y + pts[0].Y) / 2); } |
MoveAll関数は配列に格納されたライバル車すべてに対して移動の処理をおこないます。
1 2 3 4 5 |
class RivalCar { static MoveAll() { RivalCar.Cars.forEach(car => car.Move()); } } |
RivalCarクラスを継承するクラス
RivalCarクラスを継承して以下のクラスを作成します。コンストラクタの引数はXZ座標と速度です。
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 |
class BlueCar1 extends RivalCar { constructor(x: number, z: number, speed: number) { //@ts-ignore let car = CreateBlueCar1(); super(car, x, z, speed); RivalCar.Add(this); } } class BlueCar2 extends RivalCar { constructor(x: number, z: number, speed: number) { //@ts-ignore let car = CreateBlueCar2(); super(car, x, z, speed); RivalCar.Add(this); } } class RedCar1 extends RivalCar { constructor(x: number, z: number, speed: number) { //@ts-ignore let car = CreateRedCar1(); super(car, x, z, speed); RivalCar.Add(this); } } class RedCar2 extends RivalCar { constructor(x: number, z: number, speed: number) { //@ts-ignore let car = CreateRedCar2(); super(car, x, z, speed); RivalCar.Add(this); } } class YellowCar1 extends RivalCar { constructor(x: number, z: number, speed: number) { //@ts-ignore let car = CreateYellowCar1(); super(car, x, z, speed); RivalCar.Add(this); } } |
ライバル車の初期化
プログラムが開始されたらRivalCarクラスを継承して作成したクラスを使ってライバル車の初期化をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 |
// ライバル車の初期化(全部で8台) function InitCars() { new BlueCar1(155, 121, 1.0); new BlueCar1(190, 82, 0.9); new BlueCar2(240, 120, 0.7); new RedCar1(280, 80, 0.8); new RedCar2(160, 210, 0.9); new YellowCar1(185, 240, 1.0); new BlueCar1(300, 240, 0.7); new RedCar1(440, 245, 0.6); } |
自車の操作とコースアウト判定
次に自車に対しても操作とコースアウト判定をおこないます。
自車を初期化
InitMyCar関数は自車を初期化する関数です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let car1: THREE.Group = null; function InitMyCar() { //@ts-ignore car1 = CreateOrangeCar1(); // 自車の初期座標は(148, 0, 120)を expansionRate倍に拡大 car1.position.x = 148 * expansionRate; car1.position.y = 0; car1.position.z = 120 * expansionRate; let angle = GetIdealRotationY(car1); car1.rotation.y = angle; FollowJikiCamera(); } |
カメラに車を追わせる
FollowCamera関数はカメラを車を追う座標と方向にセットします。
つねに自車から8離れた位置にカメラをセットします。これで常に同じ方向から自車を撮影しているように見えます。ただし左右キーがおされているときは方向転換をしている感じを出すために、少しずらします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function FollowJikiCamera() { let rotationY = car1.rotation.y; // 方向転換をしている感じを出すために角度をずらす if (isLeftKeyDown) rotationY -= 0.04; if (isRightKeyDown) rotationY += 0.04; camera.position.x = car1.position.x - 8 * Math.sin(rotationY); camera.position.y = 3; camera.position.z = car1.position.z - 8 * Math.cos(rotationY); camera.lookAt(new THREE.Vector3(car1.position.x, car1.position.y + 1, car1.position.z)); } |
Update関数
Update関数は1秒間に60回呼び出されます。コースは1周が約2キロなので時速がそれに合うように値を調整します。1フレームあたりspeed / expansionRate * 1.05メートル移動していると考えるとちょうどよいようです。
ゴールしたときの時間を記録したいのでUpdate関数が実行された回数を数えます。レースがスタートしてしばらくしてからゴールの周辺を通過した場合、ゴールしたと判断します。
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 |
let speed = 0; // 速度 let mileage = 0; // 総走行距離 let realSpeedH = 0; // 時速 let remainingNumberUntilResurrection = 0; let updateCount = 0; let updateCountAfterGoal = 0; // ラップタイムを計算するために必要 let lastLapTime = ""; function Update() { updateCountAfterGoal++; updateCount++; // クラッシュしたため車が吹き飛んでいる if (remainingNumberUntilResurrection > 0) BlowOffMyCar(); if (remainingNumberUntilResurrection == 0) { if (IsCarInside(car1)) MoveMyCar(); // 通常の移動 else Crash(); // いままさにクラッシュした! } // ゴールしたかどうか? したならラップタイムを取得 if (IsGoal()) { if (updateCountAfterGoal > 60 * 10) { lastLapTime = (updateCountAfterGoal / 60).toFixed(1); updateCountAfterGoal = 0; } } // iフレームあたりの移動距離(単位 メートル)から総走行距離を計算する mileage += speed / expansionRate * 1.05; realSpeedH = (speed / expansionRate * 1.05) * 216; // 時速 // 時速100km以上でハンドルを切っているなら音を鳴らす PlaySoundIfTurning(); // ライバル車を移動 RivalCar.MoveAll(); // 上方にゲームに関する文字列を表示 ShowText(realSpeedH); // レンダリング renderer.render(scene, camera); requestAnimationFrame(Update); } |
クラッシュ時の処理
Crash関数はクラッシュ時の処理をおこないます。
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 xDead = 0; let zDead = 0; let xDeadPoint = 0; let zDeadPoint = 0; let soundMiss: HTMLAudioElement = new Audio('./sounds/miss.mp3'); function Crash() { // 激突音を鳴らす soundMiss.currentTime = 0; soundMiss.play(); remainingNumberUntilResurrection = 60 * 3; xDeadPoint = car1.position.x; zDeadPoint = car1.position.z; // カメラがある方向に自車を吹き飛ばす let dx = camera.position.x - car1.position.x; let dz = camera.position.z - car1.position.z; let rad = Math.atan2(dz, dx); zDead = Math.sin(rad) * 0.8; xDead = Math.cos(rad) * 0.8; // 速度は0に戻す speed = 0; } |
BlowOffMyCar関数はクラッシュしたため車が吹き飛んでいる処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function BlowOffMyCar() { car1.position.x += xDead; car1.position.y += 0.3; car1.position.z += zDead; car1.rotation.x += 0.5; car1.rotation.y += 0.5; car1.rotation.z += 0.5; remainingNumberUntilResurrection--; if (remainingNumberUntilResurrection == 0) ResurrectionCar(); } |
ResurrectionCar関数は吹っ飛んだ自車を元の位置に戻します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function ResurrectionCar() { car1.rotation.x = 0; car1.rotation.y = 0; car1.rotation.z = 0; car1.position.x = xDeadPoint; car1.position.z = zDeadPoint; let pos = GetIdealPosition(car1); let ry = GetIdealRotationY(car1); car1.position.x = pos.X; car1.position.y = 0; car1.position.z = pos.Y; car1.rotation.y = ry; FollowCamera(car1); } |
移動処理
MoveMyCar関数は通常の移動処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function MoveMyCar() { if (isLeftKeyDown) car1.rotation.y += 0.04; if (isRightKeyDown) car1.rotation.y -= 0.04; if (isUpKeyDown && speed < 2.5) // スピードが出過ぎないようにする speed += 0.05; if (isDownKeyDown) { speed -= 0.05; if (speed < 0) speed = 0; } car1.position.x += speed * Math.sin(car1.rotation.y); car1.position.y = 0; car1.position.z += speed * Math.cos(car1.rotation.y); car1.rotation.x = 0; car1.rotation.z = 0; FollowJikiCamera(); } |
PlaySoundIfTurning関数は時速100km以上でハンドルを切っているなら音を鳴らします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let soundturn: HTMLAudioElement = new Audio('./sounds/turn.mp3'); function PlaySoundIfTurning() { if (realSpeedH > 100 && !isSoundturn && remainingNumberUntilResurrection == 0) { if (isLeftKeyDown || isRightKeyDown) { isSoundturn = true; soundturn.currentTime = 0; soundturn.play(); } } if (!isLeftKeyDown && !isRightKeyDown) { soundturn.pause(); isSoundturn = false; } } |
ゴール時の処理
IsGoal関数はゴールしたかどうかを調べます。自車の初期座標は(148,0,120)なのでその周辺であればゴール地点にいることになります。
1 2 3 4 5 6 7 8 9 |
function IsGoal() { let b1 = 148 * expansionRate - 10 < car1.position.x && car1.position.x < 148 * expansionRate + 10; let b2 = 120 * expansionRate - 10 < car1.position.z && car1.position.z < 120 * expansionRate + 10; if (b1 && b2) return true; else return false; } |
AddStartLine関数はスタート地点に赤い線を引きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function AddStartLine() { let pts = GetNeerestBorderPoints(new Point(car1.position.x, car1.position.z)); //@ts-ignoreが必要 //@ts-ignore let line_geometry = new THREE.Geometry(); //頂点座標の追加 line_geometry.vertices.push( new THREE.Vector3(pts[0].X, 1, pts[0].Y), new THREE.Vector3(pts[1].X, 1, pts[1].Y), ); //線オブジェクトの生成 const line_material = new THREE.LineBasicMaterial({ color: 0xff0000 }); scene.add(new THREE.Line(line_geometry, line_material)); } |
ShowText関数はゲームに関する情報を表示するためのものです。
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 ShowText(realSpeedH) { // テキストフィールドに表示 const tf1 = document.getElementById("info1"); let str1 = "Total Time " + (updateCount / 60).toFixed(1); let str2 = "Lap Time " + (updateCountAfterGoal / 60).toFixed(1); if (lastLapTime != "") str2 += " Last Lap Time " + lastLapTime; tf1.innerHTML = str1 + " " + str2; tf1.style.transform = "translate(30px, 10px)"; tf1.style.backgroundColor = "#00008B"; tf1.style.color = "white"; tf1.style.fontSize = "18px"; // テキストフィールドに表示 const tf2 = document.getElementById("info2"); let str3 = Math.round(realSpeedH).toString() + " Km / h"; tf2.innerHTML = str3; tf2.style.transform = "translate(30px, 40px)"; tf2.style.backgroundColor = "#00008B"; tf2.style.color = "white"; tf2.style.fontSize = "18px"; } |
HTMLはこのようになっています。
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 |
<!DOCTYPE html> <html> <head> <title>TypeScript/JavaScriptでもつくる3Dカーレースゲーム</title> <meta charset="UTF-8" /> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/110/three.min.js"></script> </head> <body> <canvas id="can"></canvas> <div id="info1" style="position: absolute; top: 0; left: 0; background: white"></div> <div id="info2" style="position: absolute; top: 0; left: 0; background: white"></div> <script src="base-classes.js"></script> <script src="get-course-border.js"></script> <script src="is-cource-inside.js"></script> <script src="get-course-inside.js"></script> <script src="rival-car.js"></script> <script src="create-car-functions.js"></script> <script src="functions.js"></script> <script src="app.js"></script> </body> </html> |
BGMをならす
PlayBGM関数はBGMをならす処理をおこないます。
let soundBgm: HTMLAudioElement = new Audio(‘./sounds/bgm2.mp3’);
1 2 3 4 5 6 |
function PlayBGM() { if (speed > 0) { soundBgm.currentTime = 1; soundBgm.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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
function Init() { document.onkeydown = OnKeyDown; document.onkeyup = OnKeyUp; // シーンを作成 scene = new THREE.Scene(); // カメラを作成 camera = new THREE.PerspectiveCamera(45, width / height, 1, 10000); // 平行光源 const light = new THREE.DirectionalLight(0xffffff); light.intensity = 2; // 光の強さを倍に light.position.set(1, 1, 1); scene.add(light); const light2 = new THREE.AmbientLight(0xFFFFFF, 0.4); scene.add(light2); // レンダラーを作成 renderer = new THREE.WebGLRenderer({ canvas: <HTMLCanvasElement>document.getElementById('can') }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); // コースを作成 AddSceneCourse(); // 自車の初期化 InitMyCar(); // 自車の初期位置にスタートラインを引く AddStartLine(); // ライバル車の初期化 InitCars(); renderer.render(scene, camera); Update(); setInterval(PlayBGM, 10000); soundBgm.currentTime = 0; soundBgm.play(); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function OnKeyDown(e: KeyboardEvent) { if (e.keyCode == 37) { isLeftKeyDown = true; } if (e.keyCode == 38) { isUpKeyDown = true; } if (e.keyCode == 39) { isRightKeyDown = true; } if (e.keyCode == 40) { isDownKeyDown = true; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function OnKeyUp(e: KeyboardEvent) { if (e.keyCode == 37) { isLeftKeyDown = false; } if (e.keyCode == 39) { isRightKeyDown = false; } if (e.keyCode == 38) { isUpKeyDown = false; } if (e.keyCode == 40) { isDownKeyDown = false; } } |
最後に本体部分を示します。
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 |
const width = 640; const height = 480; let scene: THREE.Scene; let camera: THREE.PerspectiveCamera; let renderer: THREE.WebGLRenderer; let isUpKeyDown = false; let isDownKeyDown = false; let isLeftKeyDown = false; let isRightKeyDown = false; let border1: Point[] = []; let border2: Point[] = []; let centerPoints: Point[] = []; let expansionRate = 1.6; let soundturn: HTMLAudioElement = new Audio('./sounds/turn.mp3'); let isSoundturn: boolean = false; let soundBgm: HTMLAudioElement = new Audio('./sounds/bgm.mp3'); let soundMiss: HTMLAudioElement = new Audio('./sounds/miss.mp3'); window.addEventListener('load', Init); |