MitiruEngine

02 画面に UI を出す

C++ で動いているゲームの上に、HTML/CSS で作った HUD を重ねる。中身は C++ で持って、見た目は HTML/CSS、つなぎは薄い state push。RPG のメニューやアクションのカットインの土台になるパターン。

01 で作った C++ ゲームの上に、HTML/CSS で組んだ 見た目だけのレイヤー を重ねるチュートリアルです。HUD バー、ダイアログ、メニューなど、Web のデザイン資産を活かしたい部分はここに乗ります。

このチュートリアルでは「中身は C++、画面は HTML / CSS、橋渡しは薄く」という MitiruEngine の標準形を実際のコードで体験します。

時間の目安は 20 〜 25 分先に 01 を済ませてください ── このチュートリアルは 01 で書いた純 C++ コードに UI を被せる前提で進みます。


できあがるもの

  • 黒い背景の上に、C++ で描いた水色の矩形 (前のチュートリアルと同じプレイヤー)
  • 画面の上に、HTML/CSS で組んだ HUD バー
  • HUD には「経過時間」が表示され、毎フレーム C++ から更新される
完成画面: 暗背景に水色矩形が描かれ、上部に半透明の HUD バーが乗って 00:12 と表示されている
C++ が描いた背景 + プレイヤー (Native レンダ) の上に、CSS で組んだ HUD (CEF レイヤー) が乗る。HUD の時計は C++ から push される。

役割分担をはっきりさせる

書き始める前に、それぞれの担当を整理します。これがすべての判断のもとになります。

やること どこに書く
経過時間を数える C++ (Game::update)
プレイヤーや背景の描画 C++ (Game::draw)
HUD のレイアウトと見た目 HTML / CSS
HUD に値を書き込む JS (受け取って DOM に貼るだけ)
「次は何が起きるか」を決める C++。JS では決めない

ゲームのロジックを JS に書かない、これが一番大事です。JS は受け取って表示するだけ。HTML / CSS / JS は 「画面のレイアウトと見た目を作るための道具」 だと割り切ってください。値を進めるとか、勝敗を判定するとか、そういう仕事は C++ にぜんぶ寄せます。


手順 1: ひな型を作る

CEF を含むデフォルトテンプレートでひな型を作ります。

mitiru new my-ui
cd my-ui
生成された my-ui/ フォルダの中身: src/main.cpp, assets/scene.html, mitiru.toml, .gitignore, README.md
デフォルトテンプレートには assets/scene.html が含まれる。これが CEF が読み込む HTML。

手順 2: そのまま走らせる

中身を変える前に、ひな型を走らせて初期画面を確認します。

mitiru run
ひな型を走らせた直後: グラデーション背景にプロジェクト名が中央表示
デフォルトのシーンは HTML/CSS だけで描かれている。C++ 側はまだ何も描いていない。

ここまでで、CEF レイヤーが画面に乗ることが確認できました。


手順 3: C++ で背景とプレイヤーを描く

src/main.cpp を開いて、C++ で背景と矩形を描くコードに書き換えます。01 と同じ流儀です。

#include <mitiru/Mitiru.hpp>

class MyGame final : public mitiru::Game {
public:
    mitiru::Size layout(int w, int h) override { return {w, h}; }

    void update(float dt) override {
        m_elapsed += dt;
    }

    void draw(mitiru::Screen& s) override {
        const float w = static_cast<float>(s.width());
        const float h = static_cast<float>(s.height());
        s.drawRect({0, 0, w, h}, {0.06f, 0.06f, 0.10f, 1.0f});
        s.drawRect({w * 0.5f - 24, h * 0.5f - 24, 48, 48}, {0.45f, 0.75f, 0.95f, 1.0f});
    }

private:
    float m_elapsed = 0.0f;
};

int main() {
    mitiru::Engine engine;
    MyGame game;
    mitiru::EngineConfig cfg;
    cfg.title         = "My UI";
    cfg.windowWidth   = 800;
    cfg.windowHeight  = 600;
    cfg.cefStartUrl   = "assets/scene.html";
    cfg.skipDefaultFont = true;
    engine.run(game, cfg);
}

ここまでで mitiru run すると、ひな型の HUD はそのままで、C++ の描画は HTML の に隠れてしまいます。HTML が画面いっぱいに不透明な背景を持っているからです。次の手順で HTML を「透ける HUD」に作り直します。


手順 4: HTML を「透ける HUD」に作り変える

assets/scene.html を開いて、画面いっぱいの背景をやめ、上部に細い HUD バーだけを残すように書き換えます。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>My UI</title>
<style>
  html, body {
    margin: 0;
    height: 100%;
    background: transparent;          /* 中身が透ける */
    font-family: "Noto Sans JP", "Hiragino Sans", "Yu Gothic", system-ui, sans-serif;
    color: #fff;
    overflow: hidden;
  }
  .hud {
    position: fixed;
    top: 16px;
    left: 16px;
    padding: 6px 14px;
    background: rgba(8, 12, 24, 0.55);
    border: 1px solid rgba(255, 255, 255, 0.18);
    border-radius: 8px;
    backdrop-filter: blur(6px);
    font-size: 14px;
    letter-spacing: 0.04em;
  }
  .hud__label { opacity: 0.6; margin-right: 0.6em; }
  .hud__value { font-weight: 700; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
  <div class="hud">
    <span class="hud__label">TIME</span>
    <span class="hud__value" id="hud-time">00:00</span>
  </div>

  <script src="../../../web/mitiru_runtime/mitiru_runtime.js"></script>
  <script>
    // C++ から push された scene.time を受け取って DOM に貼るだけ。
    // 「次に何をするか」は決めない。決めるのは C++ 側。
    window.mitiru.onStateChange('scene.time', (value) => {
      document.getElementById('hud-time').textContent = value;
    });
  </script>
</body>
</html>

background: transparent にしたことで、HTML レイヤーの下にいる C++ の描画 (背景 + プレイヤー矩形) が見えるようになります。HUD は画面の左上に半透明の小さなバーとして残ります。

mitiru run でこの段階を確認:

C++ の背景とプレイヤー矩形の上に、半透明の HUD バーが TIME 00:00 と表示している
C++ で描いた背景の上に HUD バーが乗る。まだ 00:00 のまま動かない。

手順 5: C++ から値を push する

src/main.cpp を書き足して、毎フレーム経過時間を scene.time という名前で JS に push します。

#includeMyGame を次のように変更してください。

#include <mitiru/Mitiru.hpp>
#include <mitiru/cef/StateStore.hpp>
#include <cstdio>

class MyGame final : public mitiru::Game {
public:
    mitiru::Size layout(int w, int h) override { return {w, h}; }

    void onStart() override {
        // CEF コンテキストから StateStore を作る。
        // これが C++ → JS の唯一の経路になる。
        if (auto* eng = engine()) {
            if (auto* ctx = eng->cefContext()) {
                m_store = ctx->makeStateStore();
            }
        }
    }

    void update(float dt) override {
        m_elapsed += dt;

        // "MM:SS" に整形して JS 側に push する。
        const int total = static_cast<int>(m_elapsed);
        char buf[8];
        std::snprintf(buf, sizeof(buf), "%02d:%02d", total / 60, total % 60);
        if (m_store) {
            m_store->set("scene.time", std::string{buf});
        }
    }

    void draw(mitiru::Screen& s) override {
        const float w = static_cast<float>(s.width());
        const float h = static_cast<float>(s.height());
        s.drawRect({0, 0, w, h}, {0.06f, 0.06f, 0.10f, 1.0f});
        s.drawRect({w * 0.5f - 24, h * 0.5f - 24, 48, 48}, {0.45f, 0.75f, 0.95f, 1.0f});
    }

private:
    float m_elapsed = 0.0f;
    std::unique_ptr<mitiru::cef::StateStore> m_store;
};

ポイント:

  • C++ 側は値を 持って整形して push する だけ。HTML の <span> の存在も、CSS のスタイルも知らない。
  • JS 側は mitiru.onStateChange で値を 受け取って DOM に書き込む だけ。「経過時間を進める」 判断はしていない。
  • 名前 (scene.time) を介して薄くつないでいる。これが signal-only bridge です。

もう一度 mitiru run:

HUD バーの TIME 表示が 00:12 のように毎秒更新されている完成画面
HUD の時計が毎秒動く。値の進行は C++、見た目の更新は JS、両者の連絡は名前 1 個だけ。

何が分かったか

  • 見た目の凝った部分は HTML / CSS に置ける。グラデーションも backdrop-filter も、Web の道具がそのまま使えます。
  • 値の本体は C++ にあります。JS はそれを表示するだけで、状態は持たない。
  • C++ と JS のあいだは state 名 1 個 で繋がっています。複雑なメッセージプロトコルは要りません。

やってはいけないパターン

  • JS 側で setInterval を使って自前で時計を進める ── 状態の二重管理になり、画面と中身がズレた瞬間にデバッグが地獄になります。時計は C++ 側だけ
  • HTML / CSS / JS に当たり判定やゲームのルールを書く ── HTML/CSS は 画面の構造とデザイン を表現するための道具で、ゲームのルールを記述する道具ではありません。混ぜると見た目を直したいだけのつもりがゲームの挙動も壊す、という事故が起きます。ルールは C++ 側だけ

次の一歩

  • 複数の値を push してみる: scene.scoreplayer.hp も同じ書き方で増やせます。
  • 画面を切り替える: 別の HTML ファイルに cefStartUrl を差し替えるか、JS 側で別の <div> を表示する、どちらでも作れます。シーンの遷移ロジックは C++ 側 (SceneRouter) に置くのが定石です。
  • JS から C++ に「クリックされた」と通知する: window.mitiru.dispatch('action.next') を JS から呼んで、C++ 側で store->onAction("action.next", ...) で受ければ、ボタン UI から C++ のロジックを動かせます。これも signal-only — JS は「何が起きたか」しか伝えません。