この記事は,Processing Advent Calendar 2019 の 9日目です
昨日の記事は, @masakiyamabe さんの, Processingでお手軽3次元データビジュアライズ(人口密度の可視化) でした
はじめに
6年ほど前から弾幕プログラミングを行っていた(最近はあまりできていないけれど)エンジニアの @WGG_SH といいます
昨年の今頃から,弾幕制作を行うコミュニティの Danmaku Artist Night Club というDiscord 鯖に入ってたまーに顔を出したりしています
日本人ほとんど居ないので割と寂しかったりします()
この記事を読んでわかること / わからないこと
- わかること
- よくありがちな弾幕の作り方
- 弾幕を作るにあたって必要な知識が少し
- わからないこと
- JavaScriptの知識
- p5.jsの知識
- GPGPUなど,パフォーマンス面を考えた弾幕の作り方
- シューティングゲームの作り方
弾幕プログラミングについて
まずはこちらの動画を見てください (作りました)
このように大量の弾を飛ばしてくるシューティングゲームに出てくるような弾幕を作っていきます
詳しくは 弾幕系シューティングなどで調べてみてください (Wikipedia)
(ゲームとかだともっと激しい弾幕の場合が多いのですが, qiitaに gif アップロードする際の容量制限に引っかかるっぽいので抑えめにしてます)
僕が過去に制作した動画などでは,3D空間での弾幕を扱うことが多かったのですが,いきなり3Dに着手するのは難易度が高く,本質的な部分以外で躓きやすいので今回は2Dに限定して説明します
また,この記事内で用意している弾幕の動画は,すべてOpenProcessingにあげておりますのでソースコードも確認できます
あとこの文章かなり長めなので,何回かに分けて読むか,時間の十分ある時に読むことをオススメします
目次
- 弾幕プログラミングの思想
- 実装にあたっての設計
- 最もシンプルな弾幕プログラム
- 弾幕プログラミング(基礎編)
- 弾幕プログラミング(応用編)
- 終わりに
1. 弾幕プログラミングの思想
あえてProcessing Advent Calendar に投稿したのは,Processingを用いて作られるGenerative Art 等と類似しているからです
プログラミングを用いて,数式で絵や図を描く,という点では同じです
特に大きく異なるのは, 描画される物体が,それぞれ独立して制御される弾であることだと思います
一度発射されれば決められたルールに従って動く弾を,考え抜いたタイミング・量で飛ばすことで1つの作品と仕上げるその工程は,Generative Artと非常に似通っていると思います
それでは,次の項から実際にプログラミングを用いて弾幕を作っていきます
2. 実装にあたっての設計
とりあえず非常にシンプルな弾幕を作りましたので,まずはこちらを御覧ください
この全方位に弾をばら撒くだけの弾幕ですが,これを作るために何が必要か考えていきます
弾を表すクラス
個々の弾が独立して動くという仕組みから,まずは弾を表現するクラスを作っていきます
ただし,後々様々な性質を持った弾を作ることを考えて,最小限の機能を持った基底クラスとして作りましょう
class Bullet {
constructor(){
this._position = createVector() // 座標
this._velocity = createVector() // 移動ベクトル
this._angle = 0 // 向き
}
update() {
this._position.add(this._velocity)
this._draw()
return this._collisionField()
}
_draw() {
}
_collisionField() {
return !(this._position.x > 0 &&
this._position.x < width &&
this._position.y > 0 &&
this._position.y < height)
}
setPosition(postion) {
this._position = postion
}
setVelocity(velocity) {
this._velocity = velocity
this._angle = this._velocity.heading()
}
}
取り合えず一旦持たせる情報は 座標 移動量 向き の3つだけにしておきます
座標と移動量だけあれば弾の軌道は表現できますが,描画する時に弾を進行方向に向けたりができないです
setVelocity(velocity) {
this._velocity = velocity
this._angle = this._velocity.heading()
}
角度は移動量から一意に定まるので,移動量を決定する時に合わせて保存するようにしておきます
p5.jsで2Dの場合, p5.Vector.heading() が利用できます
_collisionField() {
return !(this._position.x > 0 &&
this._position.x < width &&
this._position.y > 0 &&
this._position.y < height)
}
updateの返り値として,画面からはみ出たかの判定を渡します
基本的に画面外の弾まで制御する必要はないので,これが真ならその弾を消すように準備します
弾を発射するクラス
弾ができたので,次は弾の発射や,弾の制御を行うクラスを作ります
クラス名悩んだのですが,せっかくなのでそのまま Danmaku
クラスとしました
これも各弾幕ごとに個別の設定をできるように,最小限の構成で作っておきます
class Danmaku {
constructor() {
this._bullets = new Array()
}
update() {
this._updateBullets()
}
_updateBullets() {
this._bullets = this._bullets.filter(bullet => !bullet.update())
}
}
弾幕クラスでは,既に存在している弾の更新だけを行っています
updateで真を返した弾は削除されます
継承後の各クラスで弾の発射制御などを行っていきます
ここまでで,弾と弾幕の基底クラスができたので,次の項から実際に弾幕を作っていきます
3. 最もシンプルな弾幕プログラム
ここからは,先程作った基底クラスを基に弾幕を作っていきます
弾作る
class Bullet01 extends Bullet {
constructor(){
super()
}
_draw(){
fill(255)
noStroke()
push()
translate(this._position.x, this._position.y)
push()
rotate(this._angle)
rect(-15,-8,30,16)
pop()
pop()
}
}
弾自体に特別な情報を持たせたりはまだしないので,描画処理だけ書きます
先の動画で見せた長方形の弾を描画します
発射処理書く
class Danmaku01 extends Danmaku {
constructor() {
super()
}
update(){
super.update()
if(frameCount %2 === 1) {
let bullet = new Bullet01()
bullet.setPosition(createVector(width/2, height/2))
bullet.setVelocity(p5.Vector.random2D().mult(5))
this._bullets.push(bullet)
}
}
}
重要なのは, update()
の中の弾発射に関する6行です
if(frameCount %2 === 1) {
let bullet = new Bullet01()
bullet.setPosition(createVector(width/2, height/2))
bullet.setVelocity(p5.Vector.random2D().mult(5))
this._bullets.push(bullet)
}
予め動画で見せた弾幕は,このようにたった6行で記述することができます
始めの1f文で,弾の発射間隔を決め,その後弾の初期座標・移動量を設定して弾を登録します
p5のメイン処理書く
let danmaku
function setup() {
createCanvas(windowWidth, windowHeight)
danmaku = new Danmaku01()
}
function draw() {
background(0)
danmaku.update()
}
これまでのしっかりした下準備のおかげで,setup/draw ともにたったこれだけで完結します.
新しい弾幕を作った場合は, danmaku = new Danmaku01()
の部分を作ったDanmakuクラスに帰るだけで弾幕が切り替わります
完成!
こちらのリンク先で実際に動いている様子を見ることができます
これで弾幕を自由に作れるようになったので,次の項から様々な弾幕を作っていきます
4. 弾幕プログラミング (基礎編)
この項では,実際にいくつかの単調な弾幕を作ってみて,弾幕を構成する要素を身に着けます
目次
- 全方位弾
- 渦巻弾
- 連射
全方位弾
全方向に均一に弾を飛ばす,定番の弾幕です
ゲーム的には,細かい隙間をくぐり抜ける精密操作が要求されたりします
class Danmaku02 extends Danmaku {
constructor() {
super()
}
update() {
super.update()
if(frameCount%30 ===0) {
const DIV = 64
for(let i = 0; i < DIV; i++){
let bullet = new Bullet01();
bullet.setPosition(createVector(width/2, height/2))
bullet.setVelocity(createVector(1, 0).rotate(TWO_PI / DIV * i).mult(5))
this._bullets.push(bullet)
}
}
}
}
この弾幕のポイントは, 同時に複数の弾を発射する ことと,弾の発射角を均等に間引くことです
弾を同時に複数発射するのは単純で, for(let i = 0; i < DIV; i++){
の部分で行います
角度の設定は,rotate(TWO_PI / DIV * i)
の部分で, TWO_PI
は弧度法で言う360度なので,それを64等分してループカウンタごとに増やしていきます
渦巻弾
先ほどと同じように全方向に弾が飛ぶ弾幕ですが,1回毎に飛ばすのは1つだけで,発射する角度が徐々に回転していきます.
ゲームとしては,先程の全方位弾と同様に避けるには細かい操作を必要とします
class Danmaku03 extends Danmaku {
constructor() {
super()
}
update() {
super.update()
let bullet = new Bullet01()
bullet.setPosition(createVector(width/2, height/2))
bullet.setVelocity(createVector(1, 0).rotate(frameCount/10.0).mult(10))
this._bullets.push(bullet)
}
}
この弾幕では,毎フレームごとに弾を発射しているので,今までのような数フレームに1回発射のif文は不要です
rotate(frameCount/10.0)
の部分で弾の発射角度の変更を行っています.時間経過とともに回転していきます
連射
ここでは,一定間隔ごとに,同じ場所に向けて連続で弾を発射する弾幕を作ってみます
(見やすさのために,弾の発生源にマークを付けました,ゲームにおいては敵にあたるものですね)
この弾幕は,ゲームでは多くの場合ワインダー弾などと呼ばれ,避けるのは簡単ですプレイヤーの移動可能な範囲を狭めることに使われ,後々の事を考えて避けることが必要とされることが多いです
class Danmaku04 extends Danmaku {
constructor (){
super()
this._targetVector = createVector()
}
update() {
super.update()
if (frameCount % 60 === 1) {
this._targetVector= p5.Vector.random2D()
}
if (frameCount % 60 < 30 && frameCount % 5 === 1) {
let bullet = new Bullet01()
bullet.setPosition(createVector(width/2, height/2))
bullet.setVelocity(this._targetVector.normalize().mult(10))
this._bullets.push(bullet)
}
fill(128)
ellipse(width/2, height/2, 30, 30)
}
}
この弾幕では,初めて Danmaku クラスに変数を保持させました.それが _targetVector
です.
毎回ランダムな角度に飛ばす場合や,常に同じ方向に飛ばす場合は,その都度角度を設定したり,予め決めた角度に設定しておけば良いのですが,今回のような弾幕では,一定の区間内では常に同じ方向に飛ばしますが,新しい区間に入ると角度がリセットされます
そのような弾幕を作る場合クラス内に今の区間での発射角度を保持しておくのが良いと思われます
この弾幕は,60フレーム (1秒) を1つのサイクルとして行動します
if (frameCount % 60 === 1) {
this._targetVector= p5.Vector.random2D()
}
60フレームの最初で,そのサイクルにおける発射角度を決めます
(0ではなく1にしているのは,Processing / p5.js における frameCount
が1から始まるためです)
if (frameCount % 60 < 30 && frameCount % 5 === 1) {
ここでは,弾の発射ルールを定めています
前半のframeCount % 60 < 30
では,各サイクルの前半0.5秒を指し,後半のframeCount % 5 === 1
は 5フレームに1度という発射間隔を表します
具体的なフレームでいうと,1, 6, 11, 16, 21, 26, | 61, 66, 71, 77 ...
といったタイミングで発射されます
組み合わせる
ここまでのテクニックを組み合わせて弾幕を作ってみます
このような弾幕を作ってみました
後々便利そうなので,弾の色を変えられるように拡張しました
(と思ったけれど,qiitaに投稿するgifの容量制限に引っかかりそうなのであまり色つけられなさそうでした)
class Bullet02 extends Bullet {
constructor(color) {
super()
this._color = color
}
_draw(){
fill(this._color)
noStroke()
push()
translate(this._position.x, this._position.y)
push()
rotate(this._angle)
rect(-15,-8,30,16)
pop()
pop()
}
}
弾幕制御部については,ソースコードを確認してください
もしくは,この動画を見てコードを推測してみるのも良いかもしれません
5. 弾幕プログラミング (応用編)
この項では,今までよりも複雑なテクニックを用いてより多様な弾幕を作っていきます
目次
- 加速度の適用
- 発射後に曲がる弾
- ホーミング弾
- 弾を発射する弾 (オプション) の導入
加速度の適用
今までの弾幕では,発射された弾は等速直線運動のみをしていましたが,発射後に速度が変わる弾をつくってみます
弾幕ゲームとしては,プレイヤーの手前で一度止まったりするので,惑わされやすい弾幕です
弾の発射については先に作成した渦巻弾と同一ですが,発射された弾が一度中央に戻ります
class Bullet03 extends Bullet {
constructor(){
super()
this._accel = createVector()
}
update() {
this._velocity.add(this._accel)
return super.update()
}
_draw(){
fill(255)
noStroke()
push()
translate(this._position.x, this._position.y)
push()
rotate(this._angle)
rect(-15,-8,30,16)
pop()
pop()
}
setAccel(accel) {
this._accel = accel
}
}
加速度を導入した新しい弾を作りました
update()
内で,速度に加速度を加算してから従来の update()
を呼び出すようにしています
弾の描画に関して,今回のように長方形で前後の向きに区別がない場合は,速度が負の場合に特別な処理は不要ですが,弾の形が三角形など,逆方向に進む場合向きを変えなければいけない場合は,速度が負になった時に_angle
を再計算しましょう
class Danmaku06 extends Danmaku {
constructor() {
super()
}
update() {
super.update()
let bullet = new Bullet03()
bullet.setPosition(createVector(width/2, height/2))
bullet.setVelocity(createVector(1, 0).rotate(frameCount / 10.0).mult(10))
bullet.setAccel(createVector(1, 0).rotate(frameCount / 10.0).mult(-0.15))
this._bullets.push(bullet)
}
}
弾幕クラスの方は,渦巻弾で作ったものとほぼ同じで加速度を適用する処理だけ追加しています
発射後に曲がる弾
次はさっきの項と似ていますが,発射してから曲がる弾を作ります
ゲームとしては,弾が自分に当たるかの判断が直前までしづらく,避けられると思っていた弾にあたってしまったり,大きく避けることが必要になる弾幕です
class Bullet04 extends Bullet {
constructor() {
super()
this._speed = 1
this._angularVelocity = 0
}
update() {
this._angle += this._angularVelocity
this._reCalculateVelocity()
return super.update()
}
_draw(){
fill(255)
noStroke()
push()
translate(this._position.x, this._position.y)
push()
rotate(this._angle)
rect(-15,-8,30,16)
pop()
pop()
}
_reCalculateVelocity() {
this.setVelocity(createVector(1, 0).rotate(this._angle).mult(this._speed))
}
setAngle(angle) {
this._angle = angle
}
setAngularVelocity(anglularVelocity) {
this._angularVelocity = anglularVelocity
}
setSpeed(speed) {
this._speed = speed
}
}
この弾幕では,角速度の概念を導入するとともに, 角度と速度から移動ベクトルを再計算する,いわゆる**極座標系に近い考え方を使用しています
_reCalculateVelocity()
で,現在の_angle
と _speed
から_velocity
を再計算しています
class Danmaku07 extends Danmaku {
constructor() {
super()
}
update() {
super.update()
if (frameCount % 8 === 0) {
for( let i = 0; i < 6; i++ ) {
let bullet = new Bullet04()
bullet.setPosition(createVector(width / 2, height / 2))
bullet.setSpeed(5)
bullet.setAngle(TWO_PI / 6 * i)
bullet.setAngularVelocity(0.010)
this._bullets.push(bullet)
}
}
}
}
弾幕クラスの方は,発射前に角速度を決定している点意外は特筆すべき箇所はありません
bullet.setAngularVelocity(0.010)
の引数を変えることで,曲がる鋭さを変更できます
https://www.openprocessing.org/sketch/807277
(この弾幕の注意点として,画面の解像度が大きいと発射した弾が画面外に出ず元の場所に戻ってくる場合があります,その場合画面に弾が増え続けるので処理が非常に重くなります. ゲームとしては,解像度を固定したり,発射ご一定時間が経過すると角速度を0に変えるなどの工夫が必要でしょう)
ちなみに,角速度ではなく 角加速度 なども導入すると,一部界隈でトラウマとされる へにょり弾幕 なるものも作れたと思います
ホーミング弾
プレイヤーなどに向かって進行方向を徐々に変える弾を作ってみます
追尾感を与えるために,今回は発射された瞬間のマウス座標を追尾の目的地とします
class Bullet05 extends Bullet {
constructor() {
super()
this._targetPosition = createVector()
}
update() {
let speed = this._velocity.mag()
this.setVelocity(this._velocity.add(this._targetPosition.copy().sub(this._position).normalize().mult(0.1)).normalize().mult(speed))
return super.update()
}
_draw(){
fill(255)
noStroke()
push()
translate(this._position.x, this._position.y)
push()
rotate(this._angle)
rect(-15,-8,30,16)
pop()
pop()
}
setTargetPosition(position) {
this._targetPosition = position
}
}
弾に持たせる情報は,目的地だけですが, update()
内の処理が少々複雑になっています
this.setVelocity(this._velocity.add(this._targetPosition.copy().sub(this._position).normalize().mult(0.1)).normalize().mult(speed))
1行に色々詰め込んでしまってるので,少し分解して考えましょう
まず,update()
内で setVelocity()
を呼び出しているので,毎フレーム弾の進行方向を更新します
なんか.normalize().mult(speed)
最後に注目すると,speed
は現在の速度なので弾の速度は更新前と同じになっていることがわかります
なんか
の中を覗いてみましょう
this._velocity.add(this._targetPosition.copy().sub(this._position).normalize().mult(0.1))
現在の移動ベクトルに,またまた複雑なものを加算しています.
これがホーミングを実現するためのパラメーターとなるのだと推測できます
そしてこの末尾にも
なんか.normalize().mult(0.1)
となっているので,長さ0.1 のホーミング用ベクトルと, 長さ5(今回は) の元のベクトルを合成して,再度長さを元の5に戻していることがわかります.
これを毎フレーム繰り返すと,少しずつベクトルの向きが変わっていくことが想像できると思います
また,この0.1の部分を大きくすると,ホーミングの曲がり具合が強くなるはずです
そしてホーミング用のベクトルは
this._targetPosition.copy().sub(this._position)
弾の現在地→目的地 を指すベクトルです
このようにしてホーミング弾は実現できます
ただし,この実装には欠点が1つあり,弾の初期の進行方向が目的地と正反対の場合,一切ホーミングしない 事です
向きが反対の場合,ホーミング用ベクトルとの加算は速度を弱める役割しか持たず,最後の mult(speed)
で元の速度に戻るからです
class Danmaku08 extends Danmaku {
constructor() {
super()
}
update() {
super.update()
if(frameCount % 60 === 0) {
for (let i = 0; i < 12; i++) {
let bullet = new Bullet05()
bullet.setPosition(createVector(width / 2, height / 2 ))
bullet.setVelocity(createVector(1, 0).rotate(TWO_PI / 12 * i).mult(5))
bullet.setTargetPosition(createVector(mouseX, mouseY))
this._bullets.push(bullet)
}
}
}
}
弾の方の解説が長くなってしまいましたが,弾幕クラスは非常にシンプルなので,もうここまで読まれた方には説明は不要かと思います
弾を発射する弾 (オプション) の導入
今回最後に説明するのは,弾を撃つ弾 です
ゲームにおいては,大ボスが,ザコ敵を召喚して,そのザコ敵が弾を発射するようなシチュエーションに近いです
class Bullet06 extends Bullet {
constructor() {
super()
this._count = 0
}
update() {
this._count++
return super.update() || this._count === 60
}
_draw() {
fill(192)
noStroke()
ellipse(this._position.x, this._position.y, 30, 30)
}
getPosition() {
return this._position.copy()
}
}
今回新しく作成したオプション用の弾クラスです
変更点は,特殊な弾だと認知させるために円形で描画した点と,玉を発射した跡に自己消滅するためのカウンタとを追加した点と,弾幕クラスから自身の座標を参照するためのゲッターを追加した点です (JS的にはゲッター無くてもアクセスできますが...)
あと,弾の座標にアクセスする機能は基底クラスにあっても良いかもって作ってから思いました
class Danmaku09 extends Danmaku {
constructor() {
super()
}
update() {
super.update()
if(frameCount % 180 === 0) {
for( let i = 0; i < 6; i++) {
let bullet = new Bullet06()
bullet.setPosition(createVector(width / 2, height / 2))
bullet.setVelocity(createVector(1, 0).rotate(TWO_PI / 6 * i).mult(4))
this._bullets.push(bullet)
}
}
if(frameCount % 60 == 59) {
this._bullets.forEach((optionBullet, index) => {
if(optionBullet instanceof Bullet06){
for( let i = 0; i < 16; i++ ) {
let bullet = new Bullet01()
bullet.setPosition(optionBullet.getPosition())
bullet.setVelocity(createVector(1, 0).rotate(TWO_PI / 16 * i).mult(3))
this._bullets.push(bullet)
}
}
})
}
}
}
今回の弾幕クラスです
3秒に1回,オプション弾を生成し,その弾が消失する直前に,弾のあった座標から新しいたまを生成します
オプションが6個,オプションから発する弾が16個なので,96個と大量の弾が同時に発射されます
弾の量が増えすぎないように注意が必要な手法です
ちなみに,今回は Bullet06 から Bullet01 を生成していますが,自分自身を生成するような事も可能です.その場合,ねずみ算や指数関数的に段数が増える恐れがあるので段数管理には更に注意が必要です
もしくは,Bullet06 側に弾を発射する処理を書くのもアリかもしれませんね,その場合はBulletから bullets にアクセスすることになる点は注意です
終わりに
ここで,初めに見せた弾幕へのリンクも張っておきます
今回扱ったテクニックはかなり基本的な部分だけなのですが,この記事の内容を身につければ,十分に多彩な弾幕を作ることができると思います
この記事を読んで,弾幕プログラミングをする人がもっと増えると嬉しいです
「○○な弾幕が作ってみたい!」 などあれば,私に言ってもらえると凄く喜びます
また,この記事かなり急いで書いたため,間違っている点など有りましたらご指摘いただけると助かります
それでは,ここまで長文読んでくださりありがとうございました!
おまけ
どうやら弾幕を量産するアドベントカレンダーもあるようですね...👀
https://adventar.org/calendars/4803