弾幕シューティングゲーム完成記念!(タイトル、メニュー)の実装
弾幕シューティングゲームミイナの大冒険完成
2ヶ月前から作り始めた弾幕シューティングゲームがついに完成しました!
Twitterなどで応援してくださったみなさまありがとうございました!
ゲーム本体はこちらからダウンロードできます。
こちらのアオイネコさんが主催するV作品紹介のイベントが2/14までだったので、最後の方は1日16時間ペースでプログラミングしてました。最後の数日でタイトル、コンティニュー、エンディング、敵出現スクリプトすべて書くというかなり無茶なことをしましたが、おかげですごく充実した日々を送ることができました。
そこで、最後に作業した内容の説明をしたいと思います。
ゲームプログラミングの骨格
今まで、自キャラ、敵、エフェクト、背景など様々なオブジェクトを作ってきましたが、これらを作る時の元となる骨格はすべて同じです。
- 登録
- 初期化
- 計算
- 描画
- 終了
この5つの処理を記述するのは、ゲームプログラミングをする上ですべて共通になります。
メニュー画面の作り方
上の様なメニュー選択がタイトル、コンティニュー、オプション画面など様々な場所で共通で使える仕様を考えてみます。
MenuSelect関数で選択しているMenu番目の位置を上キーで上の項目へ、下キーで下の項目へ移動します。
DrawMenu関数
・第一引数 タイトル文字のXの中心座標
・第二引数 一番上のメニュー項目のY座標
・第三引数 タイトル名
・第四引数 メニュー項目名の配列
1.GetDrawStringWidthToHandleでタイトルの中心座標を求める
2.DrawStringToHandleでタイトル文字を描画
3.選択用のボックスを描く座標を計算
4.SetDrawBlendModeとDrawBoxで文字の背景の四角を描画
5.GetDrawStringWidthToHandleでメニュー項目を1つずつ求めDrawStringToHandleで項目文字を描画
以上の仕様で計算部でMenuSelectを呼び、描画部でDrawMenuを呼べばどのシーンでもメニュー画面を作ることができます。
public abstract class CScene
{
public int Life = 1;
public CScene()
{
}
public virtual void Move() { }
public virtual void Draw() { }
protected int Menu = 0;
public void MenuSelect(string[] str)
{
//選択制御
if (GetPadKey("Up") == 1 && Menu > 0)
{
SGP.PlaySound(15);
--Menu;
}
else if (GetPadKey("Up") == 1)
{
SGP.PlaySound(15);
Menu = str.Length - 1;
}
if (GetPadKey("Down") == 1 && Menu < str.Length - 1)
{
SGP.PlaySound(15);
++Menu;
}
else if (GetPadKey("Down") == 1)
{
SGP.PlaySound(15);
Menu = 0;
}
}
public void DrawMenu(int x, int y, string title, string[] str)
{
int dx = 0, len = 0;
//タイトル表示
dx = x - DX.GetDrawStringWidthToHandle(title, title.Length, SGP.Font[1]) / 2;
DX.DrawStringToHandle(dx, y - 50, title, SGP.Color[0], SGP.Font[1]);
for (int i = 0; i < str.Length; ++i)
{
//メニューの中で一番長い文字列だったら
if (len < DX.GetDrawStringWidthToHandle(str[i], str[i].Length, SGP.Font[0]))
{
len = DX.GetDrawStringWidthToHandle(str[i], str[i].Length, SGP.Font[0]);
}
}
//選択用のボックスを描く座標を代入
dx = x - len / 2;
DX.SetDrawBlendMode(DX.DX_BLENDMODE_ALPHA, 100);
DX.DrawBox(dx - 10, y+(Menu*20), dx + 10 + len, y+(Menu*20) + 18, SGP.Color[5], 1); //選択位置の表示
DX.SetDrawBlendMode(DX.DX_BLENDMODE_NOBLEND, 0);
//項目の表示
for (int i = 0; i < str.Length; ++i)
{
dx = x - DX.GetDrawStringWidthToHandle(str[i], str[i].Length, SGP.Font[0]) / 2;
DX.DrawStringToHandle(dx, (y+i*20), str[i], SGP.Color[0], SGP.Font[0]);
}
}
}
タイトルとオプション画面
・タイトル画面ではStateでタイトル画面かオプション画面の判定
・MenuではTitleStrやOptionStr の項目を指し示します。
・CalcOption関数でジョイパッドの設定の変更を行っています。
public class CTitle : CScene
{
public CTitle()
{
SGP.Paused = true;
SGP.PlayMusic(8);
for (int i = 0; i < NUMBUTTONS; i++)
ButtonState[i] = 0;
}
int State = 0;
const int TitleX = 150;
const int TitleY = 250;
string[] TitleStr =
{
"ゲームスタート",
"オプション",
"終了"
};
//項目の座標
int OptionX = 150;
int OptionY = 250;
string[] OptionStr =
{
"通常ショット",
"ボム",
"低速移動",
"ポーズ",
"チェンジ",
"コンティニュー",
"戻る"
};
public override void Move()
{
switch(State)
{
case 0: // タイトル
MenuSelect(TitleStr);
switch (Menu)
{
case 0: // ゲームスタート
if (GetPadKey("Shot") == 1)
{
SGP.PlaySound(16);
State = 2;
Menu = 0;
}
break;
case 1: // オプション
if(GetPadKey("Shot") == 1)
{
State = 1;
Menu = 0;
}
break;
case 2: // 終了
if (GetPadKey("Shot") == 1)
{
DX.DxLib_End();
}
break;
}
break;
case 1: // オプション
MenuSelect(OptionStr);
switch (Menu)
{
case 0: // 通常ショット
break;
case 1: // ボム
break;
case 2: // 低速移動
break;
case 3: // ポーズ
break;
case 4: // チェンジ
break;
case 5: // コンティニュー
if(GetPadKey("Right") == 1 || 30 < GetPadKey("Right"))
{
if(SGP.MaxContinue < 30)
{
++SGP.MaxContinue;
SGP.NowContinue = SGP.MaxContinue;
}
}
if(GetPadKey("Left") == 1 || 30 < GetPadKey("Left"))
{
if(0 < SGP.MaxContinue)
{
--SGP.MaxContinue;
SGP.NowContinue = SGP.MaxContinue;
}
}
break;
case 6: // 戻る
if (GetPadKey("Shot") == 1)
{
State = 0;
Menu = 0;
}
break;
}
CalcOption();
break;
case 2: // フェードアウトしてゲーム画面へ
SGP.Bright -= 4;
if (SGP.Bright < 4)
{
State = 3;
SGP.StopAllMusic();
SGP.Paused = false;
SGP.Bright = 0;
SGP.SetStage();
SGP.PlayerBuf.Add(new CNormalPlayer(x: FMX / 2, y: 240, life: 1));
SGP.Borad.Add(new CBorad());
}
break;
case 3:
//タイトル画面に入ったときフェードインする処理
if (SGP.Bright < 252)
{
SGP.Bright += 4;
}
if (SGP.Bright > 251)
{
SGP.Bright = 255;
Life = 0;
}
break;
}
}
//ジョイスティックの状態
int[] ButtonState = new int[NUMBUTTONS];
void CalcOption()
{
int pad_input = DX.GetJoypadInputState(DX.DX_INPUT_PAD1); // パッドの入力状態を取得
for (int i = 0; i < NUMBUTTONS; i++)
{
if ((pad_input & 1 << i) == 0)
{
ButtonState[i] = 0;//押してなかったら0を代入
}
else
{
++ButtonState[i];//キーを押していたらインクリメント
}
}
//十字キーは判定に入れたくないのでi = 4から始める
for (int i = 4; i < NUMBUTTONS; i++)
{
if (ButtonState[i] == 1 && Menu == 0)
{
InputType["Shot"] = i;
SGP.PlaySound(15);
}
else if (ButtonState[i] == 1 && Menu == 1)
{
InputType["Bom"] = i;
SGP.PlaySound(15);
}
else if (ButtonState[i] == 1 && Menu == 2)
{
InputType["Slow"] = i;
SGP.PlaySound(15);
}
else if (ButtonState[i] == 1 && Menu == 3)
{
InputType["Start"] = i;
SGP.PlaySound(15);
}
else if (ButtonState[i] == 1 && Menu == 4)
{
InputType["Change"] = i;
SGP.PlaySound(15);
}
}
if(Menu == 5)
{
if (GetPadKey("Shot") == 1)
{
SGP.PlaySound(17);
Menu = 0;
}
}
}
public override void Draw()
{
switch(State)
{
case 0: // タイトル画面
DX.DrawGraph(0, 0, SGP.ImageTitle, 1);
DrawMenu(TitleX, TitleY, "メニュー", TitleStr);
break;
case 1: // キーコンフィグ設定
DX.DrawGraph(0, 0, SGP.ImageTitle, 1);
DrawMenu(OptionX, OptionY, "キー設定", OptionStr);
DrawOption();
break;
case 2:
DX.DrawGraph(0, 0, SGP.ImageTitle, 1);
DrawMenu(TitleX, TitleY, "メニュー", TitleStr);
break;
}
}
const int NUMBUTTONS = 32;
const int STR_POS_X = 220;
const int STR_POS_Y = 250;
void DrawOption()
{
DX.DrawString(STR_POS_X, STR_POS_Y, $"{InputType["Shot"]}", DX.GetColor(0, 0, 255));
DX.DrawString(STR_POS_X, STR_POS_Y + 20, $"{InputType["Bom"]}", DX.GetColor(0, 0, 255));
DX.DrawString(STR_POS_X, STR_POS_Y + 40, $"{InputType["Slow"]}", DX.GetColor(0, 0, 255));
DX.DrawString(STR_POS_X, STR_POS_Y + 60, $"{InputType["Start"]}", DX.GetColor(0, 0, 255));
DX.DrawString(STR_POS_X, STR_POS_Y + 80, $"{InputType["Change"]}", DX.GetColor(0, 0, 255));
DX.DrawString(STR_POS_X, STR_POS_Y + 100, $"回数{SGP.MaxContinue}", DX.GetColor(0, 0, 255));
}
}
コンティニュー画面
はい、いいえの選択→フェードアウトしてゲーム画面へ→タイトル画面に入ったときフェードインという流れで処理をしてます。MenuSelectとDrawMenuをタイトルとオプション画面の遷移で使った時と同じように使います。
public class CContinue : CScene
{
int State = 0;
string[] ContinueStr =
{
"はい",
"いいえ",
};
int x = FX + FMX / 2, y = FY + FMY / 3;
public CContinue()
{
SGP.Paused = true;
}
public override void Move()
{
MenuSelect(ContinueStr);
switch(State)
{
case 0: // 選択中
switch (Menu)
{
case 0: // はい
//コンティニュー数0 で メニュー「はい」が選択されてたら「いいえ」にする
if (SGP.NowContinue <= 0 )
Menu = 1;
if(GetPadKey("Shot") == 1)
{
--SGP.NowContinue;
SGP.PlayerNum = SGP.PlayerMax;
Life = 0;
SGP.Paused = false;
SGP.EffectBuf.Add(new CPlayerClashEffect(FMX / 2 + 20, FMY));
}
break;
case 1: // いいえ
if (GetPadKey("Shot") == 1)
{
SGP.SpelName = "NULL";
SGP.NowContinue = SGP.MaxContinue;
SGP.PlayerNum = SGP.PlayerMax;
State = 2;
}
break;
}
break;
case 2: // フェードアウトしてゲーム画面へ
SGP.Bright -= 4;
if (SGP.Bright < 4)
{
State = 3;
SGP.StopAllMusic();
SGP.Paused = false;
SGP.Shot.Clear();
SGP.Boss.Clear();
SGP.Bullet.Clear();
SGP.Borad.Clear();
SGP.Lazer.Clear();
SGP.Back.Clear();
SGP.SceneBuf.Add(new CTitle());
}
break;
case 3:
//タイトル画面に入ったときフェードインする処理
if (SGP.Bright < 252)
{
SGP.Bright += 4;
}
if (SGP.Bright > 251)
{
SGP.Bright = 255;
Life = 0;
}
break;
}
}
public override void Draw()
{
DrawMenu(x, y, "コンティニューしますか?", ContinueStr); //最大コンティニュー数MaxContinue回まで
int temp = SGP.NowContinue;
string str = string.Empty;
int len = 0;
if (temp > 0)
{
str = $"残り{temp}回コンティニューできます";
len = DX.GetDrawStringWidthToHandle(str, str.Length, SGP.Font[0])/2;
DX.DrawStringToHandle(x - len, y + 80, str, SGP.Color[0], SGP.Font[0]);
}
else
{
str = "コンティニューできません";
len = DX.GetDrawStringWidthToHandle(str, str.Length, SGP.Font[0]) / 2;
DX.DrawStringToHandle(x - len, y + 80, str, SGP.Color[0], SGP.Font[0]);
}
}
}