以前、C#で3Dっぽい縦シューティングゲームをつくりました。
これはC# formアプリケーションですが、今回はネットでも遊べるようにThree.jsでつくってみることにします。といってもJavaScriptでつくるのはきついのでTypeScriptでつくります。コンパイルするときにエラーがあることがわかるので安心して使えます。
仕様はかつて作成したものとほとんど変わりません。前作はカメラを前方へ移動させていましたが、今回はカメラの位置は固定です。地面に描画された直線をうごかして戦闘機が前方へ移動しているように見せかけます。
自機以外はスプライトをつかって描画します。自機は水平な板の上にテクスチャを描画します。自機だけスプライトを使わないのはスプライトだと不自然に見えてしまうからです。カメラは自機の後方やや上方にあるので水平な板の上にテクスチャを描画するほうが自然にみえます。
作成するクラスは
敵タイプ1とタイプ2、ボス敵の動作を管理するEnemy1クラス、Enemy2クラス、Bossクラス、これらの基底クラスであるEnemyクラス
自機と敵が発射する弾丸を管理するJikiBurretクラスとEnemyBurretクラス
爆発を管理するExplosionクラス
これらの基底クラスであるGameCharacterクラス
自機の動作を管理するJikiクラス、
それからサウンドを再生するためのPlaySoundeffect、PlayBattleBGM、PlayBattleBossBGクラスを作成します。
それではGameCharacterクラスを示します。
弾丸や敵キャラが共通してもつものとして存在する座標、移動している方向、ライフがあります。またスプライトをつかった描画をするのでその生成に必要なマテリアル、マテリアルを取得するメソッド、Sceneへの追加と除去の処理があります。これらをまとめたのがGameCharacterクラスです。
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 |
class GameCharacter { constructor() { } public life: number = 1; public size: number = 1; public sprite: THREE.Sprite = null; GetMaterial(dataURL: string): THREE.SpriteMaterial { let img = new Image(); img.src = dataURL; var texture = new THREE.Texture(img); texture.needsUpdate = true; return new THREE.SpriteMaterial({ map: texture }); } public AddScene(scene: THREE.Scene) { scene.add(this.sprite); } public RemoveScene(scene: THREE.Scene) { scene.remove(this.sprite); } get X(): number { return this.sprite.position.x; } get Y(): number { return this.sprite.position.y } get Z(): number { return this.sprite.position.z; } set X(value: number) { this.sprite.position.x = value; } set Y(value: number) { this.sprite.position.y = value; } set Z(value: number) { this.sprite.position.z = value; } VX: number = 0; VY: number = 0; VZ: number = 0; Move(): void { this.X += this.VX; this.Y += this.VY; this.Z += this.VZ; } } |
次に敵の移動を管理するためのEnemyクラスを示します。ゲームにおける敵の特徴は撃墜すると得点できることです。またGameCharacterクラスと同じ機能を必要としています。そこでGameCharacterクラスを継承してEnemyクラスをつくります。
1 2 3 |
class Enemy extends GameCharacter { score: number = 40; } |
はい、これだけです。これを継承して他のタイプの敵をつくります。
Enemy1クラスを示します。この敵は3回弾丸を命中させないと死なない敵です。左右ジグザグに移動しながら接近してくる敵です。弾丸を発射するかはときどき乱数をつかって決めています。
最初の出現場所のY座標は30とし、X座標は乱数で決めます。また最初の横の移動を右左どちらにするかも乱数で決めます。
ボスを倒すとステージクリアなのですが、ステージがあがるにつれてだんだん動きがはやくなります。また弾丸を撃つ頻度もあがっていきます。
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 88 |
class Enemy1 extends Enemy { static enemyMaterial10: THREE.SpriteMaterial = null; static enemyMaterial11: THREE.SpriteMaterial = null; static enemyMaterial12: THREE.SpriteMaterial = null; static enemyMaterial13: THREE.SpriteMaterial = null; constructor() { super(); // ピンク色のやさぐれひよこを使う if (Enemy1.enemyMaterial10 == null) Enemy1.enemyMaterial10 = this.GetMaterial(DataURL.dataURL_Enemy10); if (Enemy1.enemyMaterial11 == null) Enemy1.enemyMaterial11 = this.GetMaterial(DataURL.dataURL_Enemy11); if (Enemy1.enemyMaterial12 == null) Enemy1.enemyMaterial12 = this.GetMaterial(DataURL.dataURL_Enemy12); if (Enemy1.enemyMaterial13 == null) Enemy1.enemyMaterial13 = this.GetMaterial(DataURL.dataURL_Enemy13); this.sprite = new THREE.Sprite(Enemy1.enemyMaterial10); this.life = 3; this.score = 40; //最初の出現場所のY座標は30とし、X座標は乱数で決める this.X = Math.random() * 12 - 6; this.Y = 30; this.Z = 0; //最初の横の移動は右か左かも乱数で決める if (Math.random() > 0.5) this.moveRight = true; } moveCount: number = 0; moveRight: boolean = false; Move() { this.moveCount++; let additions: number = Stage; if (GameStatus == Status.GameOver) additions = 0; this.VY = -0.1 - 0.01 * additions; if (this.moveRight && this.X > 8) this.moveRight = false; else if (!this.moveRight && this.X < -8) this.moveRight = true; this.VX = 0.1 + 0.02 * additions; if (!this.moveRight) this.VX *= -1; let a: number = 60 - 5 * additions; if (a < 20) a = 20; if (this.moveCount % a == 0 && Math.random() > 0.5 && this.Y > 2) this.Shot(); let i = this.moveCount % 32; if (i < 8) this.sprite.material = Enemy1.enemyMaterial10; else if (i < 16) this.sprite.material = Enemy1.enemyMaterial11; else if (i < 24) this.sprite.material = Enemy1.enemyMaterial12; else this.sprite.material = Enemy1.enemyMaterial13; super.Move(); } Shot() { // ゲームオーバー以降のデモ画面では弾丸は発射しない if (GameStatus == Status.GameOver) return; let x = jiki.X - this.X; let y = jiki.Y - this.Y; let angle1 = Math.atan2(y, x); let speed = 0.15 + 0.01 * Stage; let burret = new EnemyBurret(this.X, this.Y, this.Z, Math.cos(angle1) * speed, Math.sin(angle1) * speed, 0); EnemyBurrets.push(burret); burret.AddScene(scene); } } |
Enemy2クラスを示します。この敵は1回の命中で死ぬ敵ですが、近くまで近づいてきて3発の弾丸を同時発射し、上方へ退避するという嫌な動き方をする敵です。
タイプ1の敵と同様、最初の出現場所のY座標は30とし、X座標は乱数で決めます。また最初の横の移動を右左どちらにするかも乱数で決めます。そしてステージがあがるにつれて動きもはやくなります。
硬い敵ではないのですが、接近後上方へ退避するという動きは撃墜しにくいです。撃墜時の点数はタイプ1と同じ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 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 88 89 90 91 92 93 94 95 96 97 98 |
class Enemy2 extends Enemy { static enemyMaterial20: THREE.SpriteMaterial = null; static enemyMaterial21: THREE.SpriteMaterial = null; static enemyMaterial22: THREE.SpriteMaterial = null; static enemyMaterial23: THREE.SpriteMaterial = null; constructor() { super(); // 黄色のやさぐれひよこを使う if (Enemy2.enemyMaterial20 == null) Enemy2.enemyMaterial20 = this.GetMaterial(DataURL.dataURL_Enemy20); if (Enemy2.enemyMaterial21 == null) Enemy2.enemyMaterial21 = this.GetMaterial(DataURL.dataURL_Enemy21); if (Enemy2.enemyMaterial22 == null) Enemy2.enemyMaterial22 = this.GetMaterial(DataURL.dataURL_Enemy22); if (Enemy2.enemyMaterial23 == null) Enemy2.enemyMaterial23 = this.GetMaterial(DataURL.dataURL_Enemy23); this.sprite = new THREE.Sprite(Enemy2.enemyMaterial20); this.life = 1; this.score = 40; this.X = Math.random() * 12 - 6; this.Y = 30; this.Z = 0; if (Math.random() > 0.5) this.moveRight = true; } away: boolean = false; moveRight: boolean = false; moveCount: number = 0; Move() { this.moveCount++; if (this.Y < 8) { this.away = true; this.Shot(); } if (this.away) this.VY = 0.3; else this.VY = -0.3; let additions: number = Stage; if (GameStatus == Status.GameOver) additions = 0; if (this.moveRight && this.X > 8) this.moveRight = false; else if (!this.moveRight && this.X < -8) this.moveRight = true; this.VX = 0.1 + 0.02 * additions; if (!this.moveRight) this.VX *= -1; let i = this.moveCount % 32; if (i < 8) this.sprite.material = Enemy2.enemyMaterial20; else if (i < 16) this.sprite.material = Enemy2.enemyMaterial21; else if (i < 24) this.sprite.material = Enemy2.enemyMaterial22; else this.sprite.material = Enemy2.enemyMaterial23; super.Move(); } Shot() { if (GameStatus == Status.GameOver) return; let x = jiki.X - this.X; let y = jiki.Y - this.Y; let angle1 = Math.atan2(y, x); let angle2 = angle1 + 0.3; let angle3 = angle1 - 0.3; let speed = 0.1 + 0.01 * Stage; let burret1 = new EnemyBurret(this.X, this.Y, this.Z, Math.cos(angle1) * speed, Math.sin(angle1) * speed, 0); EnemyBurrets.push(burret1); burret1.AddScene(scene); let burret2 = new EnemyBurret(this.X, this.Y, this.Z, Math.cos(angle2) * speed, Math.sin(angle2) * speed, 0); EnemyBurrets.push(burret2); burret2.AddScene(scene); let burret3 = new EnemyBurret(this.X, this.Y, this.Z, Math.cos(angle3) * speed, Math.sin(angle3) * speed, 0); EnemyBurrets.push(burret3); burret3.AddScene(scene); } } |
敵キャラの最後にBossを示します。こいつを倒すとステージクリアです。上方から降りてくるように登場し、左右に移動しながら円状に大量の弾丸をばらまきます。またlifeが低下してくると雑魚キャラも戦いに参戦します。ボスのlifeが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 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 |
class Boss extends Enemy { static bossSplite: THREE.Sprite = null; static isLock: boolean = true; static LifeMax = 100; constructor() { super(); if (Boss.bossSplite == null) { let img = new Image(); img.src = DataURL.dataURL_Boss; var texture = new THREE.Texture(img); texture.needsUpdate = true; const material = new THREE.SpriteMaterial({ map: texture }); Boss.bossSplite = new THREE.Sprite(material); } this.size = 8.0; this.sprite = Boss.bossSplite; this.sprite.scale.set(this.size, this.size, this.size); this.life = Boss.LifeMax; this.score = 5000; this.X = 0; this.Y = 25; this.Z = 10; // 最初の場所は(0,25,10)。(0,25,2)まで降りてきて対ボス戦の開始となる。 if (Math.random() > 0.5) this.moveRight = true; // 出現後3秒間は左右に移動しないし攻撃もしない。 // このときに自機の弾丸を命中させてもノーカウントとなる Boss.isLock = true; setTimeout(this.UnLock, 3000); } UnLock() { Boss.isLock = false; } moveCount: number = 0; moveRight: boolean = false; Move() { if (this.Z > 2) this.Z -= 0.05; else this.Z = 2; if (Boss.isLock) return; this.moveCount++; this.VY = 0; if (this.moveRight && this.X > 8) this.moveRight = false; else if (!this.moveRight && this.X < -8) this.moveRight = true; this.VX = 0.1 + 0.02 * Stage; if (!this.moveRight) this.VX *= -1; if (this.moveCount % 40 == 0) this.Shot(); super.Move(); } Shot() { let speed = 0.15 + 0.01 * Stage; for (let i = 0; i < 48; i++) { let angle = 2 * Math.PI * i * 7.5 / 360; let startX = this.X + 8 * Math.cos(angle); let startY = this.Y + 8 * Math.sin(angle); let burret1 = new EnemyBurret(startX, startY, 0, Math.cos(angle) * speed, Math.sin(angle) * speed, 0); EnemyBurrets.push(burret1); burret1.AddScene(scene); } } } |
敵の弾丸の位置や移動を管理するためのEnemyBurretクラスを示します。飛ぶときに少しだけ大きさを変化させます。ただし当たり判定はかわりません。THREE.SpriteMaterialを取得する処理と飛行時のマテリアルの変更以外はたいした処理はしていません。
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 |
class EnemyBurret extends GameCharacter { static enemyBurretMaterial0: THREE.SpriteMaterial = null; static enemyBurretMaterial1: THREE.SpriteMaterial = null; // コンストラクタで弾丸の出現位置、移動量、移動方向をセットする constructor(x: number, y: number, z: number, vx: number, vy: number, vz: number) { super(); if (EnemyBurret.enemyBurretMaterial0 == null) EnemyBurret.enemyBurretMaterial0 = this.GetMaterial(DataURL.dataURL_EnemyBurret0); if (EnemyBurret.enemyBurretMaterial1 == null) EnemyBurret.enemyBurretMaterial1 = this.GetMaterial(DataURL.dataURL_EnemyBurret1); this.sprite = new THREE.Sprite(EnemyBurret.enemyBurretMaterial0); this.life = 1; this.size = 0.3; this.X = x; this.Y = y; this.Z = z; this.VX = vx; this.VY = vy; this.VZ = vz; } moveCount: number = 0; Move() { this.moveCount++; super.Move(); let i = this.moveCount % 16; if (i < 8) { this.sprite.material = EnemyBurret.enemyBurretMaterial0; this.sprite.scale.set(this.size, this.size, this.size); } else { this.sprite.material = EnemyBurret.enemyBurretMaterial1; this.sprite.scale.set(this.size * 1.5, this.size * 1.5, this.size * 1.5); } } } |
自機の弾丸の位置と移動を管理するためのJikiBurretクラスを示します。EnemyBurretクラスよりも単純です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class JikiBurret extends GameCharacter { static BurretMaterial: THREE.SpriteMaterial = null; constructor(x: number, y: number, z: number, vx: number, vy: number, vz: number) { super(); if (JikiBurret.BurretMaterial == null) { JikiBurret.BurretMaterial = this.GetMaterial(DataURL.dataURL_JikiBurret); } this.sprite = new THREE.Sprite(JikiBurret.BurretMaterial); this.size = 0.3; this.sprite.scale.set(this.size, this.size, this.size); this.life = 1; this.sprite.position.set(x, y, z); this.VX = vx; this.VY = vy; this.VZ = vz; } } |
爆発の火球(?)の位置と移動を管理するためのExplosionクラスを示します。スプライトとしてどれを使うかの違いだけで基本的に弾丸の処理とかわりません。
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 |
class Explosion extends GameCharacter { static explosionMaterial0: THREE.SpriteMaterial = null; static explosionMaterial1: THREE.SpriteMaterial = null; static explosionMaterial2: THREE.SpriteMaterial = null; static explosionMaterial3: THREE.SpriteMaterial = null; static explosionMaterial4: THREE.SpriteMaterial = null; static explosionMaterial5: THREE.SpriteMaterial = null; constructor(x: number, y: number, vx: number, vy: number, vz: number, size: number) { super(); if (Explosion.explosionMaterial0 == null) Explosion.explosionMaterial0 = this.GetMaterial(DataURL.dataURL_Explosion0); if (Explosion.explosionMaterial1 == null) Explosion.explosionMaterial1 = this.GetMaterial(DataURL.dataURL_Explosion1); if (Explosion.explosionMaterial2 == null) Explosion.explosionMaterial2 = this.GetMaterial(DataURL.dataURL_Explosion2); if (Explosion.explosionMaterial3 == null) Explosion.explosionMaterial3 = this.GetMaterial(DataURL.dataURL_Explosion3); if (Explosion.explosionMaterial4 == null) Explosion.explosionMaterial4 = this.GetMaterial(DataURL.dataURL_Explosion4); if (Explosion.explosionMaterial5 == null) Explosion.explosionMaterial5 = this.GetMaterial(DataURL.dataURL_Explosion5); this.sprite = new THREE.Sprite(Explosion.explosionMaterial0); if (typeof size !== 'undefined') this.sprite.scale.set(size, size, size); this.life = 1; this.X = x; this.Y = y; this.Z = 0; this.VX = vx; this.VY = vy; this.VZ = vz; } moveCount: number = 0; Move() { this.moveCount++; let i = this.moveCount % 60; if (i < 4) this.sprite.material = Explosion.explosionMaterial0; else if (i < 8) this.sprite.material = Explosion.explosionMaterial1; else if (i < 12) this.sprite.material = Explosion.explosionMaterial2; else if (i < 16) this.sprite.material = Explosion.explosionMaterial3; else if (i < 20) this.sprite.material = Explosion.explosionMaterial4; else if (i < 24) this.sprite.material = Explosion.explosionMaterial5; else this.life = 0; super.Move(); } } |
効果音を鳴らすためのPlaySoundeffectクラスを示します。ここで一番大変だったのは気の利いた効果音を探すことだったのかも?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class PlaySoundeffect { static Explosion() { let audioElm: HTMLAudioElement = new Audio('./sound/explosion.mp3'); audioElm.play(); } static Damage() { let audioElm: HTMLAudioElement = new Audio('./sound/damage.mp3'); audioElm.play(); } static BigExplosion() { let audioElm: HTMLAudioElement = new Audio('./sound/big-explosion.mp3'); audioElm.play(); } } |
BGMを鳴らすためのPlayBattleBGMクラスとPlayBattleBossBGMクラスを示します。前者は通常の戦闘時、後者は対ボス戦のときのBGMです。戦闘が長引くと音が切れてしまうのと、最後が変なところで終わっているので、最後まで再生しないで適切な場所で巻き戻しています。どこまで再生されているのかを調べて、超えている場合は適切な場所にシークします。
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 |
class PlayBattleBGM { static audioElm: HTMLAudioElement = new Audio('./sound/battle.mp3'); static Start() { PlayBattleBGM.audioElm.currentTime = 0.5; PlayBattleBGM.audioElm.play(); } static Stop() { PlayBattleBGM.audioElm.pause(); } static Check() { if (PlayBattleBGM.audioElm.currentTime >= 21.4) { PlayBattleBGM.Start(); } } } class PlayBattleBossBGM { static audioElm: HTMLAudioElement = new Audio('./sound/boss.mp3'); static Start() { PlayBattleBossBGM.audioElm.currentTime = 0; PlayBattleBossBGM.audioElm.play(); } static Restart() { PlayBattleBossBGM.audioElm.currentTime = 3.5; PlayBattleBossBGM.audioElm.play(); } static Stop() { PlayBattleBossBGM.audioElm.pause(); } static Check() { if (PlayBattleBossBGM.audioElm.currentTime >= 44.5) { PlayBattleBossBGM.Restart(); } } } |
最後にJikiクラスを示します。これはスプライトを使っているわけではないので他のクラスのようにGameCharacterクラスを継承していません。戦闘機のPNG画像からテキスチャをつくって平面にはりつけています。
new THREE.MeshStandardMaterial({ map: texture });ではなく、transparent: true もいれておくのが地味に大切で、これをしないと背景色を変更したときに平面がある部分だけ黒いままで変に目立ってしまうという問題がおきます。
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 |
class Jiki { Mesh: THREE.Mesh; LifeMax = 10; life = 0; size = 1.0; MoveLeft: boolean = false; MoveRight: boolean = false; MoveFront: boolean = false; MoveBack: boolean = false; constructor() { let img = new Image(); img.src = DataURL.dataURL_Jiki; let texture = new THREE.Texture(img); texture.needsUpdate = true; const material = new THREE.MeshStandardMaterial({ map: texture, transparent: true }); let geometry = new THREE.PlaneGeometry(this.size, this.size); this.Mesh = new THREE.Mesh(geometry, material); this.life = this.LifeMax; } Move() { if (this.MoveLeft) { if (this.X > -4) { this.X += -0.1; this.Mesh.rotation.set(0, -Math.PI * 2 * 20 / 360, 0); return; } } if (this.MoveFront) { if (this.Y < 4) { this.Y += 0.1; } } if (this.MoveRight) { if (this.X < 4) { this.X += 0.1; this.Mesh.rotation.set(0, Math.PI * 2 * 20 / 360, 0); return; } } if (this.MoveBack) { if (this.Y > 0) { this.Y += -0.1; } } this.Mesh.rotation.set(0, 0, 0); } Shot() { let burret = new JikiBurret(this.X, this.Y, this.Z, 0, 0.3, 0); JikiBurrets.push(burret); burret.AddScene(scene); } public AddScene(scene: THREE.Scene) { scene.add(this.Mesh); } public RemoveScene(scene: THREE.Scene) { scene.remove(this.Mesh); } get X() { return this.Mesh.position.x; } get Y() { return this.Mesh.position.y } get Z() { return this.Mesh.position.z; } set X(value: number) { this.Mesh.position.x = value; } set Y(value: number) { this.Mesh.position.y = value; } set Z(value: number) { this.Mesh.position.z = value; } } |
では次回はこれらのクラスをつかってゲームとして完成させます。