ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その3
第3回は前回のアクションマルチゲームの動きを変えたものをやっていきます。
追加で気をつけるところを紹介します。
サンプルとして下のものを作りました。音は抜いています。
[2021/10/8追記]バグ修正しファイルを更新しました
■目次
- ジャンプ
- 当たり判定
- レーザーの当たり判定と表示
- 分割画像の外周映り込み
- 効果音の重複再生防止
- 生ゲームプレイ中ページへの掲出
- 開発者ツールによるエラー箇所の特定
- アツマールマルチ対応 [2021/6/21追記]
ジャンプ
ダックジャンプではジャンプをすることで障害物を飛び越えますが、
道路のどこに立っているかと画像の位置はずれることになります。
それを考慮したy座標の管理が必要です。
ジャンプによって一時的に画像のy座標は上に移動しますが、道路の奥の方に移動したわけではないので、道路のどこの位置に立っているかは別の数値を用意します。
この道路のどこの位置に立っているかを body.tag.y にしました。
画像の位置は body.y ですね。
ジャンプの高さも用意して body.tag.jump にしました。
body.tag.yはジャンプ中は移動分しか変えず、715行目で影をこの位置においています。
body.tag.jumpを642行目で経過時間body.tag.frameで放物線になるように計算して、
686行目で道路の位置とジャンプの高さからbody.yを決めて操作しています。
if (body.tag.state == "jump" || body.tag.state == "sjump"){
if (body.tag.frame < jumpframe) { //ジャンプ時間のカウント
body.tag.jump = ( (jumpframe/2) **2 - (body.tag.frame - jumpframe/2) **2)*playerjump;
} else { //終われば停止状態に戻す
body.tag.jump = 0;
body.tag.state = "stop";
︙
}
body.y = body.tag.y - body.tag.jump; //足場の位置からジャンプの高さ分上にずらして表示する
当たり判定
今回の2ゲームでは円形と四角形の別々の当たり判定を使用しました。
キャノンウォーズ
弾もキャラクターも丸い当たり判定を想定して、以下のような式で計算しています。
hit = ( (shot.x-player[hitid].body.x)**2 + (shot.y-player[hitid].body.y)**2) < (size/2 + playersize/2)**2;
shotとbodyの距離をx座標y座標から算出し、それぞれの当たり判定の大きさsizeの合計と比較しています。二乗で比較していて、平方根の計算は不要なのでやってません。
この方法だと細長い弾はうまくいきません。レーザーは後述する方法で計算していますが、リップルショットは外見とは違ってただの円形にしてごまかしています。
ダックジャンプ
障害物に四角い箱や細長いものがあるので、四角い当たり判定にしています。
if (Math.abs(enemy.x - player[id].body.x) < (sizex/2 + playersizex/2)){
if (Math.abs(enemy.y + sizeh/2 - player[id].body.tag.y - playersizeh/2) <= (sizey/2 + playersizey/2 + 4)){
細かい補正が入っていますが、プレイヤーと障害物のx座標y座標の差を各々計算しています。
当たり判定の大きさにもx方向の大きさsizexと、y方向の大きさsizeyがあります。
その他の方法
当たり判定については最近こちらで公開された逆引きリファレンスにシンプルな記載方法がのっていました。今回のサンプルコードではまだ使っていませんが、紹介しておきます。
3種類あるようです。
エンティティが回転や拡大をする場合
g.Collision.intersectEntities(entity1, entity2)
当たり判定の大きさはエンティティの大きさになります。
画像を使う場合は見た目と当たり判定が合わなくなりそうです。
当たり判定用のg.Rectとそれに追随する画像を使うと良いと思います。
エンティティが回転や拡大をしない場合
g.Collision.intersectAreas(entity1, entity2)
処理が軽量になるので、大量のエンティティにはこちらが推奨されています。
ただし条件として、エンティティ同士が同じ親を保つ必要があります。
あと、回転だけでなくanchorX,anchorYを0以外にしても位置がずれるようです。
自分は0.5で統一してるので、ちょっと相性が悪いですね。
entity1とentity2の大きさが同じだと影響が出なくて問題ないのですが。
円形の当たり判定でいい場合
g.Collision.withinAreas(entity1, entity2, dist)
こちらがさらに軽いそうです。distにはエンティティ同士の距離のしきい値を入力します。
大量に弾をばらまくような場合はこちらがいいでしょう。
当たり判定と画像の中心が合うように、anchorXとanchorYの調整が必要かもしれません。
使い方
これらの文字列が接触していれば true、接触していなければfalseになるので、
if (g.Collision.withinAreas(entity1, entity2, dist)) { … }
または、
let hit = g.Collision.withinAreas(entity1, entity2, dist);
if (hit) { … }
のようにして使いましょう。
レーザーの当たり判定と表示
レーザーの当たり判定は非常に細長くなっています。
一般的にゲーム業界ではどうやって計算するのか知りませんが、以下の4つを考えました。
- レーザーの先端に丸い当たり判定を作る
- 直線状に丸い当たり判定をたくさん配置する
- g.Collision.intersectEntitiesをつかう
- 直線からどれくらい離れているかと直線上の位置を計算する
1つ目は問題があって、レーザーの速度が早すぎてプレイヤーを飛び越す恐れがあります。
レーザーは1フレームあたり100ピクセル進むので、これより小さいものには当たらない可能性が出てきます。
今回別途用意したバウンドショットは壁に当たるときに加速するようになっていますが、最大速度でもプレイヤーを飛び越さないように調整しています。
2つ目は方式としては可能だと思いますが今回は採用しませんでした。
しかし、今回のレーザーは細めなので細かく当たり判定を配置する必要があり、処理が重くなる可能性があります。太いレーザーや弾が連なって見えるようなビームならうまくいくかもしれません。
3つ目が一番簡単にできたかもしれません。
四角の形を伸ばしていくことで細長い当たり判定を配置できたはずです。
ただg.Collision.intersectEntities()の場合、処理の重さがどうなるか気になりますね。
最終的に4つ目を採用しましたが、中高の数学のような座標変換をやりました。
与えられる変数は以下の5つです。
- プレイヤーの座標 body.x、body.y
- レーザーの先端の弾の座標 shot.x、shot.y
- レーザーの方向 angle
これらから、原点からの距離 dist と レーザー上の位置 pos に変換します。
略称は勝手につけてますのでゲーム的にも数学的にも一般的ではないはずです。
822行目から計算しています。
let laserd;
let laserp;
let inip;
if (type == 8) laserd = shot.x * Math.sin(angle/180*Math.PI) - shot.y * Math.cos(angle/180*Math.PI);
if (type == 8) laserp = shot.x * Math.cos(angle/180*Math.PI) + shot.y * Math.sin(angle/180*Math.PI);
if (type == 8) inip = x * Math.cos(angle/180*Math.PI) + y * Math.sin(angle/180*Math.PI);
Object.keys(player).forEach( hitid => { //全プレイヤーが攻撃が当たる状態にあるかチェック
if (player[hitid].body.tag.state != "hit") {
if (player[hitid].body.tag.mutekiframe < 0){
let hit = false;
if (type != 8){
hit = ( (shot.x-player[hitid].body.x)**2 + (shot.y-player[hitid].body.y)**2) < (size/2 + playersize/2)**2;
} else {
let dist = player[hitid].body.x * Math.sin(angle/180*Math.PI) - player[hitid].body.y *Math.cos(angle/180*Math.PI);
let online = (Math.abs(laserd-dist)) < (size/2 + playersize/2);
if (online){
let pos = player[hitid].body.x * Math.cos(angle/180*Math.PI) + player[hitid].body.y * Math.sin(angle/180*Math.PI);
hit = (Math.abs(pos - (laserp+inip)/2)) < (Math.abs(laserp-inip)/2 - (size + playersize*(frame > 3)));
}
}
distとposがわかったら、839行目からそれぞれ適正範囲に入っているか判定しています。
角度は右回りで大きくなるのか、レーザー位置はどっちがプラスなのか、といったことはあまり気にせずにまずプログラムを書いています。
角度の数値などを一時的にg.labelで表示するようにし、動作させながら調整しています。
分割画像の外周映り込み
レーザーの外見には、先端・根本・本体の3つの画像を配置しています。
819行目で本体のX倍率を変えて伸びているように見せています。
このときにちょっと問題があったのが、画像の分割の仕方でした。
最初は以下のような画像を用意して、先端・根本・本体に分割して配置していました。
しかしこれだと本体と根本・先端の境目に透明な線ができてうまくいきませんでした。
これは公式のこちらでも説明されていますが、分割画像を拡大すると隣の領域の画像が混ざるためです。
公式のケースでは画像が境界線ギリギリまで存在しているため、隣の画像を拡大したときに写り込んでしまうケースです。謎の線が写り込んでいるキャラクターはたまに見かけますね。
今回の場合は一番右の本体の画像を拡大すると一つ左のエリアの透明が混ざっていました。
対処方法として両サイドに同じ画像を配置して、真ん中の画像を使うことにしました。
画像を縦に並べ直すか、最初から別々の画像にしても良かったかもしれません。
効果音の重複再生防止
最初参加していなかったゲームにあとからクリックして参加しようとすると、
爆音がなってびっくりすることがあります。
これはChromeブラウザ特有の症状ですが、割とユーザーが多いブラウザです。
結構前から気になっていたものの、対処法がわからなくて放置していました。
今回、音を鳴らす直前に音を止めると抑えられそうなことがわかりました。
公式のニコニコスネークでも似たような処理がありました。
scene.assets["se_bound"].stop();
if (soundstate) scene.assets["se_bound"].play().changeVolume(id == g.game.selfId ? 0.6 : 0.24);
通常であれば音と音の間隔が十分あるので無駄な処理ですが、
Chromeの場合はゲームの起動から最初にクリックしたときまでの音が一瞬で鳴ります。
上記のように毎回止めてから鳴らすようにすれば実質1回しかならないようにできます。
ゲーム開始のSEなどの1回しかならないものは不要ですが、射撃音や破壊音などの複数回なるものは止めたほうが良いでしょう。
マルチゲームの場合、プレイ時間が長いので特に蓄積されます。
この方法の欠点は音を重ねて派手な演出にすることができないところですね。
例えば爆発が2~3重程度に重なったときに音が大きくなるのは本来ならありのはずです。
アセットを複数用意して順番に鳴らすようにすれば重なる最大数を決められそうですが、ちょっと面倒ですね。
生ゲームプレイ中ページへの掲出
ニコ生ゲームのモードはランキング”ranking”とマルチ”multi”がありましたが、
新しく”multi_admission”が追加されました。
game.jsonのsupportedModesを"multi_admission”にすると、こちらのページに掲載されるようになります。
こちらの公式の説明では、人数が足りなくて人を募集しているときに、
上の目立つところに放送が掲載されますと書いてあります。
ランキングゲームや公式のマルチではすでにあった機能ですね。
人数が足りている場合は自動でゲームが起動するようなので、
放送者の操作が増えるというデメリットは少なそうです。
また、生ゲームプレイ中のページの下側の「ニコ生ゲームをプレイ中の番組」にも掲載される様になっています。
もしかしたら”multi”のままでも掲載される仕様になったのかもしれませんが、
以前は自分のマルチゲームがどこでプレイされてるかわからず、動作確認に苦労しました。
ゲームを楽しむのに人数が必要なマルチだからこそ掲載したほうがいいでしょう。
ただ、途中参加できないゲームだとあとから来ても見てるだけになるのは難しいところです。
一方でネタ貼り付けシステムの「協力・対戦」の絞り込みには入らなくなっているようです。
そのうち改善されることを期待しましょう。
開発者ツールによるエラー箇所の特定
テスト動作中にプログラムに問題があると右下にエラーメッセージが出ます。
このメッセージだけで問題がわかるときもありますが、F12で開発者ツールを起動して詳細を確認することができます。
chromeだと下のような画面が出てきますが、下の欄にエラーがある場所が出てきます。
出ていない場合はConsoleのタブを探したり下の欄が格納されていないか確認してください。
更に、例えば main.js : 1229 をクリックすると問題のあるプログラムの行を上側に表示してくれます。
このケースでは表示されたこの行ではなく、realtimerankの定義をするときに、
let realtimerank = []; とすべき所を let realtimerank; にしたためエラーになっています。
問題の行そのものではありませんが、realtimerankの行であることがヒントになります。
残念ながらよくわからない行しか出ないこともありますが、7割ぐらいは役に立つ印象です。
エラーのせいで表示が重くなっている時は、左上の一時停止ボタンで一旦止めましょう。
[2022/12/20加筆]
以下の記事は、アツマールがサービス提供中の時点の内容です。
アツマールがサービス終了したあとの手順はこの一連の記事では説明していません。
別途、公式チュートリアルや解説記事を探してください。
アツマールマルチ対応
[2021/6/21追記]
つい先日、ゲームアツマールのマルチゲーム投稿が全体へ解禁されました。
公式の説明はこちら。
やり方は簡単で、ゲームアツマールのマルチゲームとして投稿する場合は、
game.jsonの "envinronment" に以下の文字を追加しましょう。
"atsumaru": {
"supportedModes": [
"multi"
]
}
他の文字と合わせると下のような形になるかもしれません。
"environment": {
"sandbox-runtime": "3",
"atsumaru": {
"supportedModes": [
"multi"
]
},
"niconico": {
"supportedModes": [
"multi_admission"
]
},
"external": {
"coeLimited": "0",
"atsumaru": "0"
}
},
"envinronment"の中に"sandbox-runtime"、"atsumaru"、"niconico"、"external" の4つがある形ですね。それぞれがカンマ,で区切られているかどうかも気をつけましょう。
名前取得の拡張機能もアツマールで使えますが、確認画面無しで自動で承認されます。
私の場合は拡張機能のバージョンが古くてつまづきました。
バージョンが古いとアツマールマルチでアカウント名が登録されず、ランダムな名前で登録されてしまいます。
うまく行かない方は拡張機能を更新しましょう。
上の文字の"external"に"atsumaru"の値がないと怪しいです。
アツマールにおけるg.game.selfId
一点だけ、生放送とアツマールマルチで仕様が異なるところがありました。
プレイヤーのIDとして使うg.game.selfIdです。
公式の情報はなさそうなのですが、自分で確認したところ以下のような違いがあります。
- 生放送の放送者 : ニコニコのアカウントID 1~100000000ぐらいの整数(の文字列)
- 生放送の参加者 : ニコニコのアカウントID 1~100000000ぐらいの整数(の文字列)
- アツマールマルチのホスト : "owner"という文字列
- アツマールマルチの参加者 : 0~1のランダムな少数
- アツマールシングル : 9999
異彩を放っているのが、"owner"という文字列ですね。
もし、ID番号を数字として扱ってプレイヤーの情報に使っている場合は、ちゃんと"owner"の場合も考慮しましょう。
放送で数字として扱う場合はNumber(g.game.selfId)と書かないとだめな気がします。
でもアツマールマルチだとそうでもないような…ちょっと挙動がよくわかりません。
多分プログラミング的には基礎的なことなんだと思いますが、個人的にはよくわからないので深く説明しません。できません。
アツマールマルチ参加者の0~1のランダムな数字というのも、g.game.random.generate()の事例を考えると、0以上1未満の数字ではないかと思います。
このため、アツマールマルチなのか生放送なのかを見分けるのには、
g.game.selfId == "owner" と Number(g.game.selfId < 1) でできそうです。
今回の説明は以上です。
似たようなシステムのゲームはこの記事に追加していきます。
マルチのゲームもテンプレとして改造しやすくしたいと思っていたのですが、
キャラクターのアニメーション有無や退場するタイミングなどの細かい仕様の違いあって、なかなかそのまま使えなさそうです。
その調整方法を説明できればいいんですが、変更箇所が色んな所に散らばっていて大変なので諦めました。
もちろん画像や数値を変えるだけで改造になりそうならやってもらって構いません。
あと、akashicのマルチの考え方について、記事を作成された方がいました。
こちらです。私の素人説明とはちがい安心感のある文章です。
この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。