🛠️

Godot 4.6 で「ゲームじゃないアプリ」を作る設計パターン — Web との比較

に公開

この記事は、Claude Code のクロウ🐦 くんが Holonia プロジェクトのコードベースと ADR をもとに執筆し、開発者 @tyabu12 が内容をレビュー・監修したものです。コードサンプルはすべて実際のプロジェクトコードから引用しています。

レビューで変わったところ(10回の修正)
  • _apply_mode() のコードスニペット拡充(FPS・Physics Tick 制御をモード別に明示)
  • バックグラウンド切替のガードクローズと backgrounded_changed シグナル追加
  • Comfort モードハンドラに add_theme_stylebox_override 復元行を追加
  • hud.tscn の Theme 参照に ExtResource 注釈を補足
  • 「今後の課題」セクション新設(メモリプレッシャー、アダプティブ描画、ネットワーク帯域)
  • ウォレットに「実装予定」注記を追加
  • 「プロダクションコード」→「プロジェクトコード」(未リリースの正確な表現に)
  • Zenn topics フォーマット修正(design_patternsdesignpatterns
  • ブラー比較の TODO を実際の GIF アニメーションに差し替え
  • ネットワーク同期レートの正確な記述(クライアント 10Hz 送信 + サーバー 20Hz バッチ配信)

この記事は、Holonia の開発で得た知見をシリーズでお届けする連載の第2回です。前回は Flutter と Godot の統合をやめた話をしました。

Godot はゲームエンジンですが、試みとして"アプリ"を作ってみました。

正確に言うと、バーチャルワールドアプリです。
アバターがいて、チャットができて、ウォレット(実装予定)があって、いろんなサービスにアクセスできる日常使いのプラットフォームです。ゲームのように「起動して30分遊んで閉じる」ものではなく、一日中開きっぱなしにすることを想定しています。

この記事では、Holonia World という Godot 4.6 製アプリの設計から、ゲームエンジンで「普通のアプリ」を作るときに役立つパターンを紹介します。

特に Web フロントエンドのバックグラウンドがある人に向けて、CSS や React との対比を交えながらお話ししていきますので、Godot に触れたことがなくても読める内容になっていると思います。

Holonia — まだ実験中のバーチャルワールド

Holonia は、アバターが歩き回る 3D 空間と日常ツールが一体になったプラットフォームを目指すプロジェクトです。まだリリース前の実証実験(PoC)段階ですが、技術スタックは本番を見据えて選定しました。2つのアプリで構成されています。

  • Holonia Pocket — Flutter 製のコンパニオンアプリ(チャット、ウォレット(予定)、ミニアプリ)
  • Holonia World — Godot 4.6 製の 3D アバター空間

最初は Flutter の中に Godot を埋め込むハイブリッド方式を試したのですが、ネイティブブリッジの不安定さに負けて完全に分離しました
結果的にこれが正解で、各アプリが自分の得意なことに集中できるようになりました。

World の全 UI は Godot のビルトイン UI ノード(Control 系ノード)だけで作っています。外部 UI ライブラリは使っていません。Godot の「ノード」は Web で言う DOM 要素のようなもので、ツリー構造で親子関係を持ちます。Control 系ノードは <div><button> みたいな UI 用のノード群のことです。Web で言うと「フレームワークなしで素の DOM API だけで UI を組む」くらいのストイックさですが、Godot の UI システムは思ったより強力で、ちゃんとしたアプリ UI が作れます。

Holonia World のメイン画面(3D ワールド + HUD オーバーレイ)

トライモードアーキテクチャ: Comfort / Normal / Performance

アプリを一日中開いておくなら、バッテリーと発熱はフレームレート以上に重要な指標になります。ゲームなら 60FPS を出し続けて当然ですが、ロビーでチャットしてるだけなのに端末が熱くなったらユーザーは離れますよね。

そこで私たちが導入したのが 3 段階のパフォーマンスモードです。

モード FPS Physics Tick ブラー アニメーション
Comfort 30 30 軽量 最小限
Normal (デフォルト) 30 30 フル フル
Performance 60 60 フル フル

Web で言うと prefers-reduced-motion メディアクエリに近い発想ですが、モーションだけでなく FPS・物理演算・レンダリング品質まで一括制御するのがポイントです。

実装は Autoload の ComfortMode スクリプト 1 本です。Autoload は Godot のシングルトンの仕組みで、アプリ起動時に自動でロードされてどこからでもアクセスできます。React で言う Context Provider をルートに置くようなものですね。

world/scripts/autoload/comfort_mode.gd
## ComfortMode — global 3-tier performance manager.
extends Node

signal mode_changed(mode: int)
signal backgrounded_changed(is_backgrounded: bool)

enum Mode { COMFORT, NORMAL, PERFORMANCE }

const _FPS_COMFORT := 30
const _FPS_NORMAL := 30
const _FPS_PERFORMANCE := 60
const _PHYSICS_COMFORT := 30
const _PHYSICS_NORMAL := 30
const _PHYSICS_PERFORMANCE := 60

var current_mode: Mode = Mode.NORMAL:
    set(value):
        if current_mode == value:
            return
        current_mode = value
        _apply_mode()
        mode_changed.emit(value)

current_mode のセッターで _apply_mode() を呼んで即座に反映します。React の useState + useEffect に近いパターンですが、GDScript ではプロパティセッターがその役割を担います。

バックグラウンド自動切替

特に気に入っているのがこの部分です。

world/scripts/autoload/comfort_mode.gd
func _notification(what: int) -> void:
    if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
        _on_background()
    elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
        _on_foreground()

func _on_background() -> void:
    if backgrounded:
        return
    _user_mode = current_mode          # remember user's choice
    backgrounded = true
    backgrounded_changed.emit(true)
    current_mode = Mode.COMFORT        # force battery-friendly mode

func _on_foreground() -> void:
    if not backgrounded:
        return
    backgrounded = false
    backgrounded_changed.emit(false)
    current_mode = _user_mode           # restore user's choice

アプリがバックグラウンドに入ったら自動で Comfort モードに落とし、復帰したらユーザーが選んでいたモードに戻します。Web の visibilitychange イベントで requestAnimationFrame を止めるのと同じ発想ですね。Godot の _notification() はエンジンからのライフサイクルイベントを受け取るコールバックで、フォーカスの出入りや画面回転など OS レベルの通知をここで処理できます。

モード変更に UI が反応する

各 UI コンポーネントは mode_changed シグナルを購読して、モードに応じた振る舞いを切り替えます。

world/scripts/ui/chat_hud.gd
# chat_hud.gd — Comfort モードではアニメーションを簡略化
func _ready() -> void:
    ComfortMode.mode_changed.connect(_on_comfort_mode_changed)

func _on_comfort_mode_changed(_mode: int) -> void:
    if ComfortMode.is_comfort:
        if _pulse_tween:
            _pulse_tween.kill()   # idle pulse animation off
            _pulse_tween = null
        _chat_window.add_theme_stylebox_override("panel", _original_panel_style)
    elif _chat_window.visible:
        _pulse_tween = NeonColors.start_pulse(_chat_window, self)

Web で言うと、Context の値が変わったら Consumer コンポーネントが再レンダリングされるのに似ています。Godot ではシグナル(= EventEmitter)がその役割を果たします。

UI システム: Web エンジニア向け Godot UI 入門

Godot の UI システムは、Web のレイアウトモデルと驚くほど似ています。対応表を作ってみました。

Web (CSS) Godot 説明
display: flex; flex-direction: column VBoxContainer 縦並び
display: flex; flex-direction: row HBoxContainer 横並び
display: grid GridContainer グリッド
padding MarginContainer 内側余白(名前は紛らわしいですが padding 相当です)
overflow-y: scroll ScrollContainer スクロール領域
position: fixed CanvasLayer ビューポートに固定
flex: 1 size_flags_horizontal = SIZE_EXPAND_FILL 残りスペースを埋める
align-self: flex-end size_flags_horizontal = SIZE_SHRINK_END 末尾寄せ
CSS Variables / Design Tokens NeonColors (定数クラス) デザイントークン
CSS Theme / CSS-in-JS .tres Theme リソース テーマ定義
backdrop-filter: blur() カスタムシェーダー 背景ブラー

テーマシステム: CSS Theme の Godot 版

Godot の Theme リソースは CSS のグローバルスタイルシートに相当します。ルートの Control ノードに Theme を設定すると、子孫ノード全体にスタイルが継承されます。CSS の cascade と同じですね。

# holonia_theme.tres (抜粋 — 実際はリソースファイル形式)

Button/styles/normal  → 透明背景 + teal ボーダー (alpha 0.4)
Button/styles/hover   → teal 背景 (alpha 0.12) + ボーダー強調 + glow shadow
Button/styles/pressed → magenta 背景 (alpha 0.08) + magenta ボーダー
Label/fonts/font      → Noto Sans Light + JP フォールバック
LineEdit/styles/focus  → 下線のみ + glow

Web で button:hover { background: rgba(0, 184, 212, 0.12) } と書くのと同じことを、Godot では StyleBoxFlat という構造体で表現します。角丸、ボーダー幅、影、背景色を全部一箇所で定義できるので、CSS の border-radius + box-shadow + background + border を1つのオブジェクトにまとめたイメージです。

デザイントークン: CSS Variables の代わりに

色やサイズの定数は NeonColors というクラスに集約しています。CSS Custom Properties (--accent-color: #00B8D4) の GDScript 版です。

world/scripts/ui/neon_colors.gd
## NeonColors — shared palette constants for the hologram UI theme.
class_name NeonColors

## A. Base -- frosted white panel background
const BASE := Color(1.0, 1.0, 1.0, 0.72)

## B. Text -- off-black (#222222)
const TEXT := Color(0.133, 0.133, 0.133, 1.0)

## C. Accent -- teal border / highlight (#00B8D4)
const ACCENT := Color(0.0, 0.72, 0.83, 0.8)
const ACCENT_FAINT := Color(0.0, 0.72, 0.83, 0.4)

## D. Secondary accent -- magenta pressed state
const ACCENT_SECONDARY := Color(1.0, 0.0, 1.0, 0.8)

## Border radius
const RADIUS_PANEL := 20
const RADIUS_BTN := 12

## Animation durations -- normal mode
const ANIM_OPEN_DURATION := 0.25
const ANIM_CLOSE_DURATION := 0.18

## Animation durations -- comfort mode (shorter)
const ANIM_OPEN_DURATION_COMFORT := 0.12
const ANIM_CLOSE_DURATION_COMFORT := 0.10

UI コンポーネントからは NeonColors.ACCENT のように参照するだけです。カラーを変えたければここを1箇所変えればアプリ全体に反映されます。

アニメーション duration も Comfort モード用の値を持っていて、ヘルパー関数で切り替えます。

world/scripts/ui/neon_colors.gd
static func effective_open_duration() -> float:
    return ANIM_OPEN_DURATION_COMFORT if ComfortMode.is_comfort else ANIM_OPEN_DURATION

Glassmorphism: backdrop-filter を Godot で再現する

Holonia の UI デザインは Glassmorphism(すりガラス風)です。Web なら backdrop-filter: blur(10px) の一行で済みますが、Godot にはその CSS プロパティに相当する機能がありません。

代わりにカスタムシェーダーで実現しています。シェーダーに馴染みがなくても大丈夫です。これは CSS の backdrop-filter を裏側で自作しているようなもので、「周辺ピクセルの平均を取って白を混ぜる」というシンプルな処理です。

world/shaders/glassmorphism_blur.gdshader
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture, filter_linear;
uniform float blur_amount : hint_range(0.0, 5.0) = 3.0;
uniform float frost_opacity : hint_range(0.0, 1.0) = 0.45;
uniform float alpha : hint_range(0.0, 1.0) = 1.0;

void fragment() {
    vec2 ps = blur_amount * 5.0 * SCREEN_PIXEL_SIZE;
    vec3 col = vec3(0.0);
    float w = 0.0;

    // Center (weight 4)
    col += texture(screen_texture, SCREEN_UV).rgb * 4.0; w += 4.0;

    // Inner ring: 4 cardinal (weight 2) + 4 diagonal (weight 1.5)
    col += texture(screen_texture, SCREEN_UV + vec2(ps.x, 0.0)).rgb * 2.0; w += 2.0;
    // ... 残りの 12 サンプルも同様に加重サンプリング

    col /= w;
    col = mix(col, vec3(1.0), frost_opacity);  // frosted white tint
    COLOR = vec4(col, alpha);
}

screen_texture から周辺ピクセルをサンプリングして加重平均を取り、白をミックスして「すりガラス」感を出しています。13 タップの加重ブラーなので GPU 負荷は控えめです。

このシェーダーを ShaderMaterial としてリソース化し、UI パネルの背後に置いた ColorRect に適用します。

# blur_material.tres
shader_parameter/blur_amount = 3.5
shader_parameter/frost_opacity = 0.72

Comfort モードでは blur_amount3.5 から 1.5 に下げて GPU 負荷を軽減します。

world/scripts/autoload/comfort_mode.gd
func _apply_mode() -> void:
    match current_mode:
        Mode.COMFORT:
            Engine.max_fps = _FPS_COMFORT
            Engine.physics_ticks_per_second = _PHYSICS_COMFORT
            _blur_material.set_shader_parameter("blur_amount", NeonColors.BLUR_COMFORT)
        Mode.PERFORMANCE:
            Engine.max_fps = _FPS_PERFORMANCE
            Engine.physics_ticks_per_second = _PHYSICS_PERFORMANCE
            _blur_material.set_shader_parameter("blur_amount", NeonColors.BLUR_OVERLAY)
        _:  # NORMAL
            Engine.max_fps = _FPS_NORMAL
            Engine.physics_ticks_per_second = _PHYSICS_NORMAL
            _blur_material.set_shader_parameter("blur_amount", NeonColors.BLUR_OVERLAY)

Comfort モードと Normal モードのブラー比較

コードで UI を組む: チャットバブルの例

Web フロントエンド出身者にとって親しみやすいのが、コードで動的に UI を構築するパターンです。React の JSX で map して要素を生成するのと同じことを GDScript でやっています。

チャットバブルを動的に生成する chat_hud.gd の例です。

world/scripts/ui/chat_hud.gd
func _on_chat_message(sender: String, message: String, _user_id: String = "") -> void:
    var is_self := sender == tr("You")

    # StyleBoxFlat = CSS の border + background + border-radius を1つにしたもの
    var bubble_style := StyleBoxFlat.new()
    bubble_style.bg_color = NeonColors.CHAT_SELF_BG if is_self else NeonColors.BASE
    bubble_style.border_color = NeonColors.ACCENT
    bubble_style.corner_radius_top_left = NeonColors.RADIUS_SUB
    # ... border widths, margins

    var bubble := PanelContainer.new()
    bubble.add_theme_stylebox_override("panel", bubble_style)
    # SIZE_SHRINK_END = align-self: flex-end (自分のメッセージは右寄せ)
    bubble.size_flags_horizontal = (
        Control.SIZE_SHRINK_END if is_self else Control.SIZE_SHRINK_BEGIN
    )

    var label := Label.new()
    label.text = tr("[{sender}] {message}").format({"sender": sender, "message": message})
    label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART  # word-wrap: break-word 相当
    bubble.add_child(label)

    _message_list.add_child(bubble)  # VBoxContainer に追加 = flex container に append

VBoxContainerPanelContaineradd_child していくのは、React で messages.map(m => <ChatBubble .../>) するのとそっくりです。size_flags_horizontal で左寄せ/右寄せを制御するのは align-self そのものですね。

レスポンシブデザイン

Web では @media (max-width: 768px) でブレークポイントを設定しますが、Godot では アンカーコンテナの組み合わせでレスポンシブレイアウトを実現します。

HUD のシーンファイル(.tscn — ノードのツリー構造を定義するファイルで、HTML に相当します)を見てみましょう。

# hud.tscn
[node name="HUD" type="Control"]
anchors_preset = 15           # PRESET_FULL_RECT (width: 100%; height: 100%)
anchor_right = 1.0
anchor_bottom = 1.0
theme = holonia_theme.tres    # Theme の cascade 起点 (実ファイルでは ExtResource 参照)

[node name="StatusBar" type="HBoxContainer" parent="."]
anchor_left = 1.0             # 右上に配置
anchor_right = 1.0
offset_left = -690.0          # 右端から 690px
offset_top = 80.0

ルートの Control が Full Rect(= width: 100%; height: 100%)で画面全体を覆い、子ノードがアンカーで位置決めします。HBoxContainerdisplay: flex; flex-direction: row と同じく、子要素を水平に並べます。

Safe Area(ノッチやステータスバーの領域)にも対応しています。

world/scripts/ui/chat_hud.gd
func _apply_safe_area() -> void:
    var insets := SafeArea.get_insets()
    _chat_window.offset_left = insets.left
    _chat_window.offset_top = insets.top
    _chat_window.offset_right = -insets.right

Web の env(safe-area-inset-top) と同等のことを、スクリプトで動的に適用しています。

i18n: gettext で多言語対応

国際化には私たちは Godot ビルトインの PO/gettext フォーマットを採用しました。

# English source string = translation key (no abstract IDs)
label.text = tr("Log In")

# Named placeholders for reorderable translations
label.text = tr("Welcome back, @{handle}!").format({"handle": user_handle})

Web の i18nextreact-intl を使ったことがあるなら馴染みやすいと思います。tr()t() 関数に相当し、English ソース文字列がそのままキーになります(gettext の慣例です)。

Godot の Control ノードには Auto Translate 機能があり、LabelButtontext プロパティに設定した英語テキストが、PO ファイルの msgid とマッチすれば自動的に翻訳されます。つまりシーンファイルは英語のまま保持して、PO ファイルに日本語訳を書くだけで大丈夫です。

SubViewport の罠: テーマもロケールも引き継がれない

パフォーマンス戦略まとめ

「ゲームじゃないアプリ」を Godot で作るとき、最も重要なのは何もしてない時間のコストを最小化することです。ゲームなら常にフレームを描画するのが当然ですが、アプリは「ユーザーがチャットを読んでるだけ」の時間が大半を占めます。

私たちが実践しているテクニックをまとめます。

  1. FPS キャップ: デフォルト 30FPS です。ゲーム開発者は眉をひそめるかもしれませんが、UI 操作中心なら 30FPS で十分です。スマホのバッテリー持ちが劇的に変わります。

  2. プロセス規律: 見えていないノードは process_mode = PROCESS_MODE_DISABLED にします。Web の IntersectionObserver で画面外要素の処理を止めるのと同じです。

  3. レンダリング予算: Comfort モードではブラーを軽量化し、Idle Pulse アニメーションを停止します。不要なドローコールを減らします。

  4. ネットワーク節約: WebSocket のハートビートは維持しつつ、バックグラウンドでは不要な通信を抑制します。API コールはバッチ化・デバウンスしています。

今後の課題: デイリーユースアプリの次のステップ

トライモードアーキテクチャで「何もしてない時間のコスト」は大幅に下げられましたが、一日中開きっぱなしのアプリにはまだ対処すべき課題があります。

  • メモリプレッシャーへの対応: 長時間起動し続けると、テクスチャキャッシュやシーンデータがメモリに蓄積されていきます。OS からメモリ警告が来たときに、LOD(Level of Detail)を落としたり、画面外のテクスチャキャッシュを解放するような仕組みが必要です。ゲームなら「セッションが短いからリセットされる」で済みますが、デイリーユースアプリはそうはいきません。
  • アダプティブ描画品質: 現在のトライモードはユーザーの手動切替 + バックグラウンド自動切替ですが、将来的には端末の状態に応じた自動品質調整も視野に入れています。iOS の ProcessInfo.ThermalState や Android の PowerManager から熱状態を取得して、端末が熱くなってきたら自動で描画品質を下げる仕組みです。「勝手に画質が変わった」とユーザーに感じさせないために、スムーズなトランジションが鍵になります。
  • ネットワーク帯域の適応制御: WiFi 環境ではクライアントが 10Hz でアバター位置を送信し、サーバーが 20Hz でバッチ配信していますが、モバイル回線では帯域とデータ量の両面で過剰です。接続種別(WiFi / 5G / LTE / 3G)を検知して、同期頻度やアセット品質を動的に切り替えたいと考えています。頻度を落とした分はクライアント側の補間(デッドレコニング)で補うことで、ユーザー体験の劣化を最小限に抑える方針です。

Godot で「アプリ」は現実的か?

正直に言うと、完璧ではありません

良いところ

  • レイアウトシステムが意外と強力: Container ノードの組み合わせで、Web の Flexbox に匹敵するレイアウトが組めます
  • Theme リソースが便利: CSS のようなカスケード継承で、一箇所変えれば全体に反映されます
  • シグナルシステムが自然: イベント駆動の UI 設計が React の hooks/context に近い粒度でできます
  • クロスプラットフォーム: 1 つのコードで PC + モバイルに対応できます(これは Flutter や React Native と同じですね)
  • 3D と 2D UI の統合: これは Godot ならではの強みです。3D 空間内にチャットバブルを浮かべるようなことが、同じプロジェクト内で自然にできます

足りないところ

  • アクセシビリティ: スクリーンリーダー対応が弱いです。Web の ARIA に相当する仕組みがほぼありません
  • テキスト入力: IME(日本語入力)まわりの挙動がプラットフォームによって不安定です
  • DevTools がない: Web の Inspector のようなリアルタイム UI デバッグツールがありません。Godot エディタのインスペクタでプロパティを確認できますが、実行中のアプリに対してリアルタイムにスタイルを変更するような体験はまだありません
  • エコシステム: UI コンポーネントライブラリが存在しません。Material UI や shadcn/ui のようなものは自分で作るしかありません
  • バンドルサイズ: ランタイムを含むので、シンプルなアプリでも数十 MB になります(Web アプリの基準だと大きいですね)

結論

3D 要素が UI の一部として必要なアプリなら、Godot は有力な選択肢になります。バーチャルワールド、3D ビューア、インタラクティブな教育アプリなどです。逆に 2D の CRUD アプリなら、おとなしく Flutter か React を使ったほうがいいでしょう。

Godot の本質的な強みは「3D レンダリングエンジンと UI フレームワークが同居している」ことです。Web だと Three.js + React を別々に動かして橋渡しするところを、Godot では同じシーンツリーの中で自然に共存させられます。その体験は、一度味わうと他では得がたいものです。


次回は、このアプリの裏側 — Cloudflare Durable Objects でマルチプレイヤー同期をどう実現したかをお話しします。ゲームサーバーなしでリアルタイム同期、できるんです。

Discussion