Godotでゲームを作っていると、「どのステージでプレイヤーがよく死ぬのか」とか、「どの敵が一番危険なのか」といった統計情報を取りたくなりますよね。
でも、素直に実装しようとすると…

  • 各ステージごとに HTTPRequest ノードを置いて、専用スクリプトを書く
  • Player.gd の中で直接HTTPアクセスを書き始めて、どんどん肥大化する
  • 「ステージID」「デス原因」などのパラメータを、毎回ベタ書きする

こんな感じで、「継承された巨大なPlayerクラス」「ステージごとにバラバラな実装」になりがちです。
しかも、あとから「送信先URLを変えたい」「送信フォーマットを変えたい」と思ったときに、あちこち修正する羽目になります。

そこで今回は、どのノードにもポン付けできる「分析送信コンポーネント」として、AnalyticsSender を用意してみましょう。
プレイヤーでも敵でも、チェックポイントでも、「死んだ」「クリアした」「到達した」といったイベントをコンポーネントに投げるだけで、裏側でHTTP送信してくれる仕組みです。

【Godot 4】イベントを投げるだけで分析ログ送信!「AnalyticsSender」コンポーネント

以下が、AnalyticsSender コンポーネントのフルコードです。
シーンのどこかにこのノードを追加して、他ノードから send_event() を呼ぶだけで使えるようにしています。


extends Node
class_name AnalyticsSender
## 分析用イベントをHTTP経由で送信するコンポーネント
##
## 使い方イメージ:
##   - シーンのどこかに AnalyticsSender を1つ置く
##   - Player や Enemy から:
##       analytics_sender.send_event("player_death", {"stage": "Stage1", "cause": "spike"})
##     のように呼び出すだけ

@export_category("Network Settings")
## 分析サーバーのエンドポイントURL
@export var endpoint_url: String = "https://example.com/analytics"

## HTTPメソッド。基本は POST を想定
@export_enum("POST", "GET") var http_method: String = "POST"

## タイムアウト秒数(0以下で無制限)
@export var timeout_seconds: float = 10.0

@export_category("Game Context")
## ゲーム全体で共通のタイトル名やゲームID
@export var game_id: String = "my_cool_game"

## バージョン情報。ABテストやバージョン比較に便利
@export var game_version: String = "1.0.0"

## 現在のステージID。シーンごとに上書きしてもOK
@export var stage_id: String = ""

@export_category("Privacy & Debug")
## 実機ビルドでのみ送信し、エディタ実行中は送信しない
@export var disable_in_editor: bool = true

## ログをコンソールに表示するか(開発中のデバッグ用)
@export var verbose_log: bool = true

## 実際に送信せず、コンソールに内容だけ出す「ドライラン」モード
@export var dry_run: bool = false

## 内部で使う HTTPRequest ノード
var _http_request: HTTPRequest

func _ready() -> void:
    # HTTPRequest ノードを自動で生成して子として追加
    _http_request = HTTPRequest.new()
    add_child(_http_request)
    _http_request.request_completed.connect(_on_request_completed)
    _http_request.timeout = int(timeout_seconds)
    
    if verbose_log:
        print("[AnalyticsSender] Ready. endpoint_url=%s, method=%s" % [endpoint_url, http_method])

    # ステージIDが未設定なら、現在のシーン名を使う
    if stage_id.is_empty():
        var tree := get_tree()
        if tree and tree.current_scene:
            stage_id = tree.current_scene.name


## 分析イベントを送信するメインAPI
##
## event_name: "player_death", "stage_clear" などのイベント名
## payload: イベント固有の追加データ(辞書)。任意。
func send_event(event_name: String, payload: Dictionary = {}) -> void:
    # エディタ実行中は送信をスキップ(設定による)
    if disable_in_editor and Engine.is_editor_hint():
        if verbose_log:
            print("[AnalyticsSender] Skip send_event in editor: %s" % event_name)
        return

    # エンドポイント未設定なら何もしない
    if endpoint_url.is_empty():
        push_warning("[AnalyticsSender] endpoint_url is empty. Event '%s' not sent." % event_name)
        return

    # ベースの共通データ
    var body: Dictionary = {
        "game_id": game_id,
        "game_version": game_version,
        "stage_id": stage_id,
        "event_name": event_name,
        "timestamp": Time.get_unix_time_from_system(),
    }

    # 追加のペイロードをマージ
    for key in payload.keys():
        body[key] = payload[key]

    if verbose_log:
        print("[AnalyticsSender] send_event: %s" % JSON.stringify(body))

    # ドライランモードなら送信しない
    if dry_run:
        return

    # JSONにエンコード
    var json_body := JSON.stringify(body)
    var headers := [
        "Content-Type: application/json",
    ]

    var err: Error
    if http_method == "GET":
        # GETの場合、クエリパラメータとして送る簡易実装
        var url_with_query := endpoint_url + "?data=" + URI.encode_component(json_body)
        err = _http_request.request(url_with_query)
    else:
        # POSTを想定
        err = _http_request.request(endpoint_url, headers, HTTPClient.METHOD_POST, json_body)

    if err != OK:
        push_warning("[AnalyticsSender] HTTP request error: %s" % error_string(err))


## HTTPRequest のコールバック
func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
    if not verbose_log:
        return

    var body_text := ""
    if body.size() > 0:
        body_text = body.get_string_from_utf8()

    print("[AnalyticsSender] Request completed. result=%d, code=%d, body=%s" % [
        result,
        response_code,
        body_text,
    ])


## 分かりやすいヘルパー関数例: 「プレイヤー死亡」を送信
##
## cause: "spike", "enemy", "fall" など
## extra: 追加情報(例: {"hp": 0, "x": 120.0, "y": 200.0})
func send_player_death(cause: String, extra: Dictionary = {}) -> void:
    var payload: Dictionary = {
        "cause": cause,
    }
    for key in extra.keys():
        payload[key] = extra[key]
    send_event("player_death", payload)


## ステージクリアの送信用ヘルパー
func send_stage_clear(time_sec: float, extra: Dictionary = {}) -> void:
    var payload: Dictionary = {
        "clear_time": time_sec,
    }
    for key in extra.keys():
        payload[key] = extra[key]
    send_event("stage_clear", payload)

使い方の手順

ここでは、2Dアクションゲームを例に、プレイヤーが死んだときに「どのステージで」「何が原因で」死んだかを送信する流れを見ていきましょう。

手順①: シーンに AnalyticsSender を追加する

まずは、ゲーム全体のルート(例: Main シーン)か、各ステージシーンに AnalyticsSender を1つ追加します。

Main (Node)
 ├── Player (CharacterBody2D)
 ├── LevelTiles (TileMap)
 ├── UI (CanvasLayer)
 └── AnalyticsSender (Node)

AnalyticsSender ノードを選択して、インスペクタで以下を設定します。

  • endpoint_url: 実際の分析サーバーのURL(例: https://your-server.com/api/analytics
  • game_id: ゲーム固有のID(例: "my_platformer"
  • game_version: バージョン(例: "1.2.3"
  • stage_id: ステージごとに上書きしたい場合はここに "Stage1" などを入力。
    空なら現在シーン名が自動で使われます。
  • 開発中は dry_run = true にしておくと、安全にログだけ確認できます。

手順②: Player から AnalyticsSender を参照する

プレイヤーのスクリプトから、AnalyticsSender を呼び出せるようにします。
シンプルに「シーンルートから探す」か、「エクスポート変数でドラッグ&ドロップ参照」する方法が楽です。

例: Player.gd(CharacterBody2D)


extends CharacterBody2D

@export var analytics_sender: AnalyticsSender

func _ready() -> void:
    # シーンツリーから自動で探すフォールバック
    if analytics_sender == null:
        analytics_sender = get_tree().current_scene.get_node_or_null("AnalyticsSender")

シーン構成図はこんなイメージです。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── (スクリプト: Player.gd)

Main (Node)
 ├── Player (CharacterBody2D)
 ├── ...
 └── AnalyticsSender (Node)  ← コンポーネント

手順③: 死亡時に send_player_death() を呼ぶ

プレイヤーが死んだタイミングで、AnalyticsSender にイベントを投げましょう。
プレイヤーのHP管理やダメージ処理の中に、1行だけ追加するイメージです。


func die(cause: String) -> void:
    # 死亡アニメーションやリトライ処理など…
    queue_free()

    # 位置情報なども一緒に送りたい場合
    if analytics_sender:
        analytics_sender.send_player_death(cause, {
            "x": global_position.x,
            "y": global_position.y,
        })

例えば、トゲに当たったときは cause = "spike"
穴に落ちたときは cause = "fall" のように分けておくと、後で分析しやすくなります。

手順④: 他のイベントにも使い回す(敵・動く床・チェックポイントなど)

AnalyticsSender は「プレイヤー死亡」専用ではなく、どんなイベントにも使える汎用コンポーネントです。
例えば、

  • 敵死亡イベント: send_event("enemy_killed", {"enemy_type": "slime"})
  • 動く床から落ちた回数: send_event("fall_from_moving_platform", {...})
  • チェックポイント到達: send_event("checkpoint_reached", {"index": 2})

敵シーンの例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── (スクリプト: Enemy.gd)

extends CharacterBody2D

@export var analytics_sender: AnalyticsSender
@export var enemy_type: String = "slime"

func _ready() -> void:
    if analytics_sender == null:
        analytics_sender = get_tree().current_scene.get_node_or_null("AnalyticsSender")

func die() -> void:
    queue_free()
    if analytics_sender:
        analytics_sender.send_event("enemy_killed", {
            "enemy_type": enemy_type,
        })

このように、PlayerやEnemy自体は「分析の具体的な送信処理」を一切知らず
「AnalyticsSenderにイベント名と辞書を投げるだけ」という形にしておくと、コンポーネント指向らしい綺麗な分離になりますね。

メリットと応用

AnalyticsSender コンポーネントを使うことで、次のようなメリットがあります。

  • 巨大なPlayerクラスを避けられる
    HTTP送信やJSON整形といった「インフラ寄りの処理」を、プレイヤーや敵のロジックから切り離せます。
  • シーン構造がシンプルになる
    各ステージにバラバラな HTTPRequest ノードを置く必要がなく、AnalyticsSender 1つに集約できます。
  • 使い回しがしやすい
    別プロジェクトにも、AnalyticsSender.gd をコピペして、エンドポイントURLを変えるだけで流用可能です。
  • 後から仕様変更しても安全
    送信フォーマットやヘッダ、エンドポイントを変えたくなっても、修正箇所はコンポーネント1か所だけです。
  • テストが楽
    dry_runverbose_log を使えば、実際にサーバーを立てなくてもログの中身を確認できます。

応用としては、

  • セッションIDやユーザーIDを付与して、プレイヤー単位での行動分析
  • 「1プレイ中のイベント履歴」をローカルに貯めて、まとめて送信するバッチモード
  • Godotの ConfigFileProjectSettings からエンドポイントを読み込む

など、いろいろ発展させられます。

最後に、セッションIDを自動生成して全イベントに付ける改造案のコード例を載せておきます。


var session_id: String = ""

func _ready() -> void:
    _http_request = HTTPRequest.new()
    add_child(_http_request)
    _http_request.request_completed.connect(_on_request_completed)
    _http_request.timeout = int(timeout_seconds)

    # 起動ごとにランダムなセッションIDを生成
    session_id = "%s-%d" % [OS.get_unique_id(), Time.get_unix_time_from_system()]

func send_event(event_name: String, payload: Dictionary = {}) -> void:
    if disable_in_editor and Engine.is_editor_hint():
        return

    if endpoint_url.is_empty():
        push_warning("[AnalyticsSender] endpoint_url is empty. Event '%s' not sent." % event_name)
        return

    var body: Dictionary = {
        "game_id": game_id,
        "game_version": game_version,
        "stage_id": stage_id,
        "event_name": event_name,
        "timestamp": Time.get_unix_time_from_system(),
        "session_id": session_id, # ★ ここでセッションIDを付与
    }

    for key in payload.keys():
        body[key] = payload[key]

    # 以下、送信処理は先ほどの実装と同様…

こんな感じで、「AnalyticsSender」をベースに自分のゲームに合った分析コンポーネントへ育てていくと、
継承地獄にハマらず、スッキリしたコンポーネント指向の設計ができて気持ちいいですね。