チャチャチャおもちゃの抹茶っちゃ

ゲームのこととかプログラミングのこととか。気が向いたら書く。ブログタイトルは友人が考えました。

【C++,Siv3D】ゲームランチャーを制作する

はじめに

サークル活動のひとつとして、展示ゲーム用のランチャーをC++とSiv3Dを使って作った話。

うちのサークルでは会員がゲームを作ったりしていて、新歓だとか大祭のときに制作物の展示を行っている。ただデスクトップから起動するだけでは寂しいので毎度ランチャーを用意することにしている。今回はそのランチャーをぼくが作ったので備忘録。

作り方とかの大部分はググって出てくる通りなので、ここでは詰まった点とか気をつけた点とかを書いておく。

使用する言語とライブラリ

  • C++
  • Siv3D(August 2016 v2)

使用言語はC++C#とかもあるみたいだけど触ったことがない。最近C++に結構触っていて勉強もしていたのでその流れでC++で作ることにした。というかものを作れるほど使える言語そんな多くないよね

使用するライブラリはSiv3D。

play-siv3d.hateblo.jp

当たり判定とかも関数で用意されていてかなり使いやすそうで前々から興味はあった。せっかくだからSiv3D触ってみながらランチャーつくるかなーという次第。実際使ってみるとかなり小回り効いていい感じだった。

せっかくSiv3Dを使うので、便利な当たり判定からシーン遷移まで使わせてもらいました。マジ便利。

以前DxLibの紹介記事みたいの書いておきながらあれだけど、Siv3D欲しいものがすでに揃ってるの便利すぎて戻れなくなりそう()
自作ライブラリとかゴミ!wってなる

目標

ランチャーを作る。自動読み込み機能付き。UIが説明されなくても直感的にわかるとなお良い。

技術的な話

ランチャーなので他アプリケーションを起動できないとはじまりませんね。あとはプログラムのほうで毎回起動するプログラムを追加していたらキリがないので、ディレクトリの状態からアプリケーションを自動取得する機能も欲しいです。

とりあえずググりました。「C++ ランチャー」。Qiitaの記事がヒット。あれ・・・・・・?著者がサークルの先輩ですね・・・・・・。

qiita.com

ここを見るとSiv3Dのほうの関数でディレクトリの状態とかを取得できるものがある様子。ほぼ丸パクリします。

自動読み込み機能

上記サイトのを参考にしつつ改良を加えていく。詳しい解説はここでは書きません。ほぼ同じことします。

読み込みルール

とりあえず引用。

  • ファイルの名前をゲームの名前として読み込む
  • ゲームの実行ファイル「.exe」のパスを読み込む
  • ファイルの中にある「screenshot」という名前の画像ファイルを読み込む
  • ファイルの中にある「readme.txt」というテキストファイルのテキストを読み込む
  • ファイルの中にある「info.txt」というテキストファイルのテキストの1行目と2行目を読み込む

ここからいくつか変更を加えていく。

まず強調した部分。実行ファイルがEXE形式とは限らなかったり複数ある可能性を加味して自分で実行ファイルを指定できるように。info.txtの1行目に実行ファイル名を記述するようにして、2行目からゲームジャンルなどを読み込むように仕様を修正。

スクリーンショット以外にデモムービーもロードして流すことができるようにしたいので、「demo」のつく動画ファイルも読み込むようにした。

基本的には引用ママなので、一部変えたところだけ。

// 略

// 実行ファイル名、ジャンル、操作方法のロード
if ((file.includes(L"info")) {
    TextReader reader(file);
    infoFlag = true;
    reader.readLine(appData.executePath);
    reader.readLine(appData.kind);
    reader.readLine(appData.usingtext);
    continue;
}

// 実行ファイルのパス変換
appData.executePath = path + appData.executePath;

// 略

ディレクトリのパスとinfo.txtから読み取った実行ファイル名を連結してあげてパスが完成。Siv3DのFilePath型はString型の別名でしかないので同じように扱うことができる。

あとはエラー処理とかをよしなに書き込んであげるとより良いでしょう。とりあえずぼくはうまく読み込めなかったらPrintln()デバッグ表示するようにした。

Siv3DのFileSystem::にある関数たち、なかなか使いやすいですねえ・・・・・・。

表示の都合の読み込み方変更

readme表示の都合上テクスチャにすると楽だったので変換した。以下変換コード。

// readmeのロード
if (file.includes(L"readme") || file.includes(L"readMe") || file.includes(L"Readme") || file.includes(L"ReadMe") || file.includes(L"README")) {
    // テキストファイル読み込み
    TextReader reader(file);
    String content = reader.readAll();
    readmeFlag = true;

    // イメージ変換
    Image contentImg;
    contentImg.resize(font.region(content).size, Palette::White);
    font.write(contentImg, content, Point::Zero, Palette::Black);
    appData.readme = Texture(contentImg);

    continue;
}

ところでString型で正規表現とかどう使うんでしょうね(使い方わからなかったけど調べるほどでもなかった気がして後回しにしていた)。

テクスチャ変換をすると

github.com

これが使えるので脳死でReadmeをきれいに貼り付けることができる。というかテキストをフィールド作って貼り付ける方法がうまく見つからなかったので・・・・・・。

ランチャー機能

Issue見てもわかってしまうけれど一番悩んだところ。EXE形式のファイルを開くだけなら特に問題なかった。 Siv3DのCreateProcess()ProcessInfo型の2つを使えば比較的容易に起動やランチャーの最大化、最小化ができた。参考にしたのはこれ↓

qiita.com

ちょうどよいサンプルがあったね。

問題となったのはこのあと。EXE形式以外のゲームもサークルでは作る人がいるかもしれない(HTMLゲーなど)ので、EXE形式以外のファイルも起動したい・・・・・・。CreateProcess()ProcessInfo型で管理するのはできればそのままにしたいという方向で調査を進める。

どうやら既定のプログラムで開きたいときにそのプログラムを取得する方法としてWinAPIのFindExecutable()というものがあるらしい。こちらを利用してブラウザのパスを取得してCreateProcess()にわたすことを試みる。

無事既定プログラムのパスは取得できたものの、ブラウザによってHTMLファイルを開くときのコマンドラインオプションが異なったり、そもそもCreateProcess()がうまく動かなかったりして詰まる。手詰まりになったので掲示板に質問してみた。↓

siv3d.jp

Siv3D作者のReputelessさんにもお手数をおかけして教えていただいた。Chromeで試せなかったのはぼくがガバってたらしい?恥ずかしい・・・・・・。ここでShellExecuteW()を使う手を得る。しかしこのままでは起動したプロセスが終了したかどうかとか起動中かどうかを知ることができない・・・・・・。ShellExecuteまわりで調べだすと、プロセスをハンドルできそうなShellExecuteEx()なるものがあるようなのでいじりだす。新たにプロセスを起動してそのハンドルを返す関数を実装して動かしてみるとうまく動くようだった。ハンドルのクローズをしないとメモリリークを起こすようで、この辺の実装にも気を配る必要がありそう。慣習的にはShellExecuteEx()を呼んだらすぐクローズするのがお作法なようだけどこのハンドルを使いたいのでやむを得ず。試しに次のようなコードを書いてみるとうまく外部アプリケーションを起動でき、ランチャーの最小化と復帰もできた。

# include <Siv3D.hpp>
# define  NOMINMAX
# include <Windows.h>
# include <psapi.h>

HANDLE OpenApps(const FilePath& url, const FilePath& dir)
{

    Println(FileSystem::FileName(url));
    Println(FileSystem::ParentPath(url));

    SHELLEXECUTEINFOW shellExeInfo;
    memset(&shellExeInfo, 0, sizeof(shellExeInfo));
    shellExeInfo.cbSize = sizeof(shellExeInfo);
    shellExeInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
    shellExeInfo.lpVerb = L"open";
    shellExeInfo.lpFile = url.c_str();
    shellExeInfo.lpParameters = nullptr;
    shellExeInfo.lpDirectory = dir.c_str();
    shellExeInfo.nShow = SW_SHOWNORMAL;

    ShellExecuteEx(&shellExeInfo);
    HANDLE handle = shellExeInfo.hProcess;

    return handle;
}

void Main()
{
    HANDLE handle = 0;

    const FilePath filePath = L"testapp.exe";

    while (System::Update())
    {
        if (handle == 0 || WaitForSingleObject(handle, 0) == WAIT_OBJECT_0) {
            Println(L"Restore");
            CloseHandle(handle);
            handle = 0;
            Window::Restore();
        }
        else {
            Println(L"Minimize");
            Window::Minimize();
        }
        if (Input::MouseL.clicked) {
            Println(L"clicked");
            Println(handle = OpenApps(FileSystem::FileName(filePath), FileSystem::ParentPath(filePath)));
        }

    }
}

アプリケーションの起動時とそうでないときでランチャーの状態を分けるのは、handleCreateProcess()ProcessInfoのように使うことで実現。これでうまくいったのを参考にしてランチャーのほうにも実装した。
すると次のような実装になった。起動のための関数は詳細画面用クラス内に作成した。

関数部分(Detail.cpp)

# define NOMINMAX

HANDLE Detail::openOtherApps(const FilePath & file, const FilePath & directory) {

    SHELLEXECUTEINFO shellExeInfo;
    memset(&shellExeInfo, 0, sizeof(shellExeInfo));
    shellExeInfo.cbSize = sizeof(shellExeInfo);
    shellExeInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
    shellExeInfo.lpVerb = L"open";
    shellExeInfo.lpFile = file.c_str();
    shellExeInfo.lpParameters = nullptr;
    shellExeInfo.lpDirectory = directory.c_str();
    shellExeInfo.nShow = SW_SHOWNORMAL;

    ShellExecuteExW(&shellExeInfo);

    HANDLE handle = shellExeInfo.hProcess;

    return handle;

}

呼び出し部分(Detail.cpp、updateLauncher関数内)

// あそぶボタンが押されたときの処理
// EXE形式だったらSiv3DのCreateProcessを使う
if (FileSystem::Extension(m_data->apps[m_data->selectedID].appData.executePath) == L"exe") {
    m_data->process = System::CreateProcess(m_data->apps[m_data->selectedID].appData.executePath);
}
// それ以外のときはShellExecuteを採用
else {
    m_data->handle = openOtherApps(FileSystem::FileName(m_data->apps[m_data->selectedID].appData.executePath), FileSystem::ParentPath(m_data->apps[m_data->selectedID].appData.executePath));
}

本当はShellExecuteEx()のみで全部対応できているのだけど、ShellExecute調査不足のところがあるのでとりあえずSiv3DのCreateProcess()は残した。起動ボタン処理の部分に書いてあげましょう。

ランチャーの最小化、復元部分(BaseScene.cpp)

後述するがランチャー本体の最小化、復元チェックはシーン遷移クラスの親クラスに書いておいている。

# pragma once
# include <Siv3D.hpp>
# include <Windows.h>
# include "Scene.hpp"

# define NOMINMAX

void BaseScene::update() {

    // 起動中のアプリケーションがあれば最小化、終了したら最小化解除
    if (m_data->process) {
        if (m_data->process->isRunning()) {
            Window::Minimize();
        }
        else {
            Window::Restore();
            m_data->process = none;
        }
    }
    else if (m_data->handle) {
        if (m_data->handle == 0 || WaitForSingleObject(m_data->handle, 0) == WAIT_OBJECT_0) {
            CloseHandle(m_data->handle); // 使用のおわったハンドルはクローズ
            m_data->handle = 0;
            Window::Restore();
        }
        else {
            Window::Minimize();
        }
    }
        // 起動中のアプリがなければ情報更新
    else {
        updateLauncher();
    }

}

後々では前半のOptional<ProcessInfo>型processで条件分岐してる部分は消せるかもしれない。これでメイン処理部はこの関数を継承したクラスのupdateLauncher()をオーバーライドして書くだけで良くなった。

シーン遷移

ランチャーでは次の3つのシーンを作った。

  • 選択画面
  • 詳細画面
  • デモ画面

選択画面はアイコンからゲームを選ぶ、詳細画面はゲームのReadmeを表示して開始ボタンがある、デモ画面はデモムービーなどをフルスクリーンで流したりしておく。

シーン遷移ではSiv3DのHamFrameworkを利用させてもらった。フェード処理も少しいじったけれどこちらはサンプルを参考に。↓

github.com

アプリケーションの名前とか実行ファイルのパス、スクリーンショットなどの情報はShareDataとしておいた。

すべてのシーンで共通してアプリケーションの起動チェックを行うので、シーン遷移用クラスを継承した抽象クラス(BaseScene)をつくった。フェード処理も一致しているのでこちらに書いている。update()関数の内容は上に書いたとおりである。クラス定義は次のような感じ。

using SceneChanger = SceneManager<String, ShereData>;

class BaseScene : public SceneChanger::Scene{

public:

    BaseScene() = default;

    virtual ~BaseScene() = default;

    void update() override;

    virtual void updateLauncher() = 0;

    void drawFadeIn(double t) const override {
        draw();
        FadeIn(t);
    }

    void drawFadeOut(double t) const override {
        draw();
        FadeOut(t);
    }

};

FadeIn()FadeOut()は別のところに定義してある。

あとはこのクラスを継承したSelectクラス、Detailクラス、Demoクラスを作成してシーンマネージャーにadd()すればOK。

全シーンで共通した処理があるときの小技だった。

グラフィック面

このへんは作るごとに変わったりするしあまり詳しく書くこともないので概要程度に・・・・・・。基本的には読み込んだ内容をそのままはっつけていけば良い。読み込み面と起動面だけクリアしてしまえばランチャーの制作は勝ったようなもの。

サンプルにあった付箋とか写真のフレームとか参考にしまくった、というか割とパクった

まとまったパーツはクラスにしてしまうと管理しやすいですね(ボタンとか付箋とか写真のクラスを作ってひたすら貼っつけただけの顔)

選択画面

スクロールはボタンをつけてできるだけ視覚的に。とにかくわかりやすく。

f:id:mattyan1053:20190322011233p:plain
去年の冬コミのものをランチャーにいれてみた図

詳細画面

Readmeとかの情報を表示。ムービーがあればここで流れるように。

f:id:mattyan1053:20190322015243p:plain
選択画面、ぼくが少し手伝ったゲーム

デモ画面

スクリーンセーバーみたいな感じで。2分間操作しないと自動的にデモ画面に遷移する。読み込んでおいたムービーを流したりスクリーンショットが流れたり。ここ、縦横比固定に気を配った。

デモ画面のゲーム間遷移でもシーン遷移を使っていて、シーン遷移の入れ子になっている。これはサンプルがあったので参考にした。↓

qiita.com

マウスを動かすと元の画面に戻る。Mouse::Delta()とかいうとても便利なものがあったので実装もとても容易だった。

全体的な話

文字数オーバーではみ出るとかそのへんは適当に対処した。↓をやばそうな文字列に適用しただけ。付箋とかは常にこれ適用してる。

// 文字列を幅wに収まるようにカット
String MakeTailCutString(String str, int32 w, String fontAsset) {

    String retStr = str;

    const size_t n = Max<size_t>(FontAsset(fontAsset).drawableCharacters(retStr, w), 1);
    const bool overflow = (n < str.length);

    if (overflow) {
        retStr = retStr.substr(0, n - 3);
        retStr += L"...";
    }

    return retStr;

}

最後に

以上、自分がランチャーを作ってみて躓いた点とか覚えておきたい点だった。ソースコードを全部のせていてはキリがないので切り貼りだったけどわかりにくくなってしまった・・・・・・。起動直後に呼ぶ関数だったり起動中の処理部だったりに書けばいいだけなので、完全コピペとかでやろうとしなければ困らないと思いたい。C++でランチャーを作る系の記事、ネット上に全然ないので残しておこうかと思った(探しまくった)。

実際のソースコードがみたいとかランチャーほしいって人はコチラ↓

github.com

Githubに置いとくので煮るなり焼くなりなんなりと。Issueも暇があれば見ます。タイマー機能とかそのうちつけてみると便利かなと思っている。