LLM との協働開発に適したゲームエンジン Godot でテキストベース開発を試してみた

LLM との協働開発に適したゲームエンジン Godot でテキストベース開発を試してみた

LLM との協働開発に適したゲームエンジンとして注目されている Godot 4.x を使用して、シンプルなピンポンゲームを実装した体験をレポートします。テキストベースのシーンファイルと Python ライクな GDScript により、 GUI 操作を最小限に抑えた開発アプローチを検証しました。

はじめに

本記事では、 LLM との協働開発に適したゲームエンジンとして注目されている Godot 4.x を使用して、シンプルなピンポンゲームを実装します。プレイヤーが下部のパドルを操作し CPU と対戦する 2D ゲームです。

ゲームの動作イメージ

Godot とは

Godot は、オープンソースのクロスプラットフォーム対応ゲームエンジンです。 Unity や Unreal Engine と並ぶ主要なゲーム開発ツールのひとつで、 GDScript という Python ライクな独自のスクリプト言語を採用しています。エンジン本体のサイズが軽量で、起動が高速という特徴があります。

Godot が LLM との協働開発に適している理由

Godot のシーンファイル (.tscn) は人間が読める形式で記述されており、 LLM が理解・生成しやすい構造になっています。

[node name="Player" type="CharacterBody2D" parent="."]
position = Vector2(180, 650)
script = ExtResource("2_1hdqx")

また、 GDScript は Python ライクな文法で、多くの LLM が得意とする言語パターンです。今回のようにテキストエディタのみでの開発も可能で、 LLM との対話的な開発環境を構築できます。

対象読者

  • LLM を活用したゲーム開発に興味がある開発者
  • Godot の基本的な使い方を知りたい方

参考

実装

はじめに、 Godot を 公式サイト よりダウンロードして起動し、プロジェクトを新規作成しました。

プロジェクト新規作成

今回はレンダラーとして、「単純なシーンのレンダリング速度が最も速い。」とされる 互換性 を選択しました。

レンダラー選択

プロジェクトを作成するとフォルダーが作成されます。実現したいゲームの動作イメージを LLM に伝え、フォルダー内に下記のファイルを作成しました。今回は開発用の LLM として Claude Sonnet 4 を使用しました。

Main.tscn
[gd_scene load_steps=9 format=3 uid="uid://bvxqy8qkqwqxr"]

[ext_resource type="Script" path="res://Main.gd" id="1_0hdqx"]
[ext_resource type="Script" path="res://Player.gd" id="2_1hdqx"]
[ext_resource type="Script" path="res://CPU.gd" id="3_2hdqx"]
[ext_resource type="Script" path="res://Ball.gd" id="4_3hdqx"]

[sub_resource type="RectangleShape2D" id="RectangleShape2D_1"]
size = Vector2(80, 10)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_2"]
size = Vector2(80, 10)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_3"]
size = Vector2(16, 16)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_4"]
size = Vector2(20, 720)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_5"]
size = Vector2(360, 20)

[node name="Main" type="Node2D"]
script = ExtResource("1_0hdqx")

[node name="Player" type="CharacterBody2D" parent="."]
position = Vector2(180, 650)
script = ExtResource("2_1hdqx")

[node name="CollisionShape2D" type="CollisionShape2D" parent="Player"]
shape = SubResource("RectangleShape2D_1")

[node name="ColorRect" type="ColorRect" parent="Player"]
offset_left = -40.0
offset_top = -5.0
offset_right = 40.0
offset_bottom = 5.0
color = Color(1, 1, 1, 1)

[node name="CPU" type="CharacterBody2D" parent="."]
position = Vector2(180, 70)
script = ExtResource("3_2hdqx")

[node name="CollisionShape2D" type="CollisionShape2D" parent="CPU"]
shape = SubResource("RectangleShape2D_2")

[node name="ColorRect" type="ColorRect" parent="CPU"]
offset_left = -40.0
offset_top = -5.0
offset_right = 40.0
offset_bottom = 5.0
color = Color(1, 1, 1, 1)

[node name="Ball" type="CharacterBody2D" parent="."]
position = Vector2(180, 360)
script = ExtResource("4_3hdqx")

[node name="CollisionShape2D" type="CollisionShape2D" parent="Ball"]
shape = SubResource("RectangleShape2D_3")

[node name="ColorRect" type="ColorRect" parent="Ball"]
offset_left = -8.0
offset_top = -8.0
offset_right = 8.0
offset_bottom = 8.0
color = Color(1, 1, 1, 1)

[node name="Walls" type="StaticBody2D" parent="."]

[node name="LeftWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(-10, 360)
shape = SubResource("RectangleShape2D_4")

[node name="RightWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(370, 360)
shape = SubResource("RectangleShape2D_4")

[node name="TopWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(180, -10)
shape = SubResource("RectangleShape2D_5")

[node name="BottomWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(180, 730)
shape = SubResource("RectangleShape2D_5")
Main.gd
extends Node2D

# ゲーム設定
const SCREEN_WIDTH = 360
const SCREEN_HEIGHT = 720

# スコア
var player_score = 0
var cpu_score = 0

# ゲームオブジェクトの参照
@onready var player = $Player
@onready var cpu = $CPU
@onready var ball = $Ball

func _ready():
	# 画面サイズの設定
	get_window().size = Vector2i(SCREEN_WIDTH, SCREEN_HEIGHT)
	get_window().position = Vector2i(100, 100)  # ウィンドウ位置の調整

	# ボールにスコア更新のシグナルを接続
	ball.goal_scored.connect(_on_goal_scored)

	print("Pingpong game started!")
	print("Use A/D or Left/Right arrows to move your paddle")

func _on_goal_scored(scorer):
	if scorer == "player":
		player_score += 1
		print("Player scores! Score: Player ", player_score, " - CPU ", cpu_score)
	elif scorer == "cpu":
		cpu_score += 1
		print("CPU scores! Score: Player ", player_score, " - CPU ", cpu_score)

	# ゲーム終了判定 (5点先取)
	if player_score >= 5:
		print("Player wins!")
		_reset_game()
	elif cpu_score >= 5:
		print("CPU wins!")
		_reset_game()

func _reset_game():
	# スコアリセット
	player_score = 0
	cpu_score = 0

	# ボール位置リセット
	ball.reset_position()

	print("Game reset! New game started.")

func _input(event):
	# ESC キーでゲーム終了
	if event.is_action_pressed("ui_cancel"):
		get_tree().quit()
Player.gd
extends CharacterBody2D

# パドルの設定
const SPEED = 300.0
const PADDLE_WIDTH = 80.0
const SCREEN_WIDTH = 360

# コリジョンシェイプの設定
@onready var collision_shape = $CollisionShape2D

func _ready():
	# パドルのコリジョンシェイプを設定
	var shape = RectangleShape2D.new()
	shape.size = Vector2(PADDLE_WIDTH, 10)
	collision_shape.shape = shape

func _physics_process(delta):
	# 入力の取得
	var input_direction = 0

	# キーボード入力の処理
	if Input.is_action_pressed("ui_left") or Input.is_action_pressed("move_left"):
		input_direction = -1
	elif Input.is_action_pressed("ui_right") or Input.is_action_pressed("move_right"):
		input_direction = 1

	# 速度の設定
	velocity.x = input_direction * SPEED
	velocity.y = 0  # Y軸方向の移動は制限

	# 移動の実行
	move_and_slide()

	# 画面境界での制限
	_clamp_to_screen()

func _clamp_to_screen():
	# パドルが画面外に出ないよう制限
	var half_width = PADDLE_WIDTH / 2
	position.x = clamp(position.x, half_width, SCREEN_WIDTH - half_width)

# 入力マップの設定 (プロジェクト設定で自動的に作成されるアクション以外)
func _input(event):
	# A/D キーでの移動 (追加の入力オプション)
	pass
CPU.gd
extends CharacterBody2D

# CPU パドルの設定
const SPEED = 200.0  # プレイヤーより少し遅く設定
const PADDLE_WIDTH = 80.0
const SCREEN_WIDTH = 360
const REACTION_ZONE = 360.0  # ボールがこの Y 座標より上にある時に反応

# AI の設定
const PREDICTION_FACTOR = 0.8  # ボールの予測移動への反応度
const DEAD_ZONE = 10.0  # この範囲内ではパドルを動かさない

# ゲームオブジェクトの参照
@onready var collision_shape = $CollisionShape2D
var ball_node: CharacterBody2D

func _ready():
	# パドルのコリジョンシェイプを設定
	var shape = RectangleShape2D.new()
	shape.size = Vector2(PADDLE_WIDTH, 10)
	collision_shape.shape = shape

	# ボールノードの参照を取得
	ball_node = get_parent().get_node("Ball")

func _physics_process(delta):
	if not ball_node:
		return

	# ボールの位置と速度を取得
	var ball_position = ball_node.global_position
	var ball_velocity = ball_node.velocity

	# ボールが CPU 側に向かっている場合のみ反応
	if ball_position.y < REACTION_ZONE and ball_velocity.y < 0:
		_move_towards_ball(ball_position, ball_velocity, delta)
	else:
		# ボールが遠い場合は中央に戻る
		_move_to_center(delta)

	# 移動の実行
	move_and_slide()

	# 画面境界での制限
	_clamp_to_screen()

func _move_towards_ball(ball_pos: Vector2, ball_vel: Vector2, delta: float):
	# ボールの予測位置を計算
	var time_to_reach = abs((position.y - ball_pos.y) / ball_vel.y) if ball_vel.y != 0 else 0
	var predicted_x = ball_pos.x + ball_vel.x * time_to_reach * PREDICTION_FACTOR

	# パドル中心とボール予測位置の差を計算
	var target_x = predicted_x
	var distance = target_x - position.x

	# デッドゾーン内では動かない
	if abs(distance) < DEAD_ZONE:
		velocity.x = 0
		return

	# 目標方向に移動
	var direction = sign(distance)
	velocity.x = direction * SPEED
	velocity.y = 0

func _move_to_center(delta: float):
	# 画面中央に向かって移動
	var center_x = SCREEN_WIDTH / 2
	var distance = center_x - position.x

	if abs(distance) < DEAD_ZONE:
		velocity.x = 0
		return

	var direction = sign(distance)
	velocity.x = direction * SPEED * 0.5  # 中央復帰は少し遅く
	velocity.y = 0

func _clamp_to_screen():
	# パドルが画面外に出ないよう制限
	var half_width = PADDLE_WIDTH / 2
	position.x = clamp(position.x, half_width, SCREEN_WIDTH - half_width)
Ball.gd
extends CharacterBody2D

# ボールの設定
const INITIAL_SPEED = 250.0
const MAX_SPEED = 400.0
const SPEED_INCREMENT = 20.0  # パドルに当たるたびに速度上昇
const BALL_SIZE = 16.0

# 画面設定
const SCREEN_WIDTH = 360
const SCREEN_HEIGHT = 720

# ゲーム状態
var current_speed = INITIAL_SPEED
var is_serving = true

# シグナル
signal goal_scored(scorer)

# ゲームオブジェクトの参照
@onready var collision_shape = $CollisionShape2D

func _ready():
	# ボールのコリジョンシェイプを設定
	var shape = RectangleShape2D.new()
	shape.size = Vector2(BALL_SIZE, BALL_SIZE)
	collision_shape.shape = shape

	# 初期サーブ
	_serve()

func _physics_process(delta):
	if is_serving:
		return

	# 移動の実行
	var collision = move_and_slide()

	# 衝突処理
	if get_slide_collision_count() > 0:
		_handle_collision()

	# ゴール判定
	_check_goals()

func _serve():
	# サーブ時の初期設定
	is_serving = true
	current_speed = INITIAL_SPEED
	position = Vector2(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)

	# ランダムな方向でサーブ (上下どちらかに向かう)
	var angle = randf_range(-PI/4, PI/4)  # -45度から45度の範囲
	if randf() > 0.5:
		angle += PI  # 50% の確率で下向きに

	velocity = Vector2(cos(angle), sin(angle)) * current_speed
	is_serving = false

	print("Ball served with velocity: ", velocity)

func _handle_collision():
	var collision = get_slide_collision(0)
	var collider = collision.get_collider()

	if collider:
		var collision_normal = collision.get_normal()

		# パドルとの衝突
		if collider.name == "Player" or collider.name == "CPU":
			_handle_paddle_collision(collider, collision_normal)

		# 壁との衝突
		elif collider.name == "Walls":
			_handle_wall_collision(collision_normal)

func _handle_paddle_collision(paddle, normal: Vector2):
	# パドルの中心からボールまでの相対位置を計算
	var paddle_center = paddle.global_position.x
	var ball_center = global_position.x
	var relative_intersect = (ball_center - paddle_center) / 40.0  # パドル半幅で正規化

	# 反射角度を計算 (相対位置に基づいて角度を調整)
	var bounce_angle = relative_intersect * PI/3  # 最大60度の角度

	# 速度を増加
	current_speed = min(current_speed + SPEED_INCREMENT, MAX_SPEED)

	# 新しい速度ベクトルを設定
	if paddle.name == "Player":
		# プレイヤーパドル (下から上へ)
		velocity = Vector2(sin(bounce_angle), -cos(bounce_angle)) * current_speed
	else:
		# CPU パドル (上から下へ)
		velocity = Vector2(sin(bounce_angle), cos(bounce_angle)) * current_speed

	print("Paddle hit! New speed: ", current_speed)

func _handle_wall_collision(normal: Vector2):
	# 壁との反射 (左右の壁のみ)
	if abs(normal.x) > 0.5:  # 左右の壁
		velocity.x = -velocity.x
		print("Wall bounce!")

func _check_goals():
	# 上のゴール (プレイヤーの得点)
	if position.y < -20:
		goal_scored.emit("player")
		_serve()

	# 下のゴール (CPU の得点)
	elif position.y > SCREEN_HEIGHT + 20:
		goal_scored.emit("cpu")
		_serve()

func reset_position():
	# ゲームリセット時の位置初期化
	_serve()

# デバッグ用の速度表示
func _input(event):
	if event.is_action_pressed("ui_accept"):  # スペースキー
		print("Ball position: ", position, " Velocity: ", velocity, " Speed: ", current_speed)

また、 project.godot を次のように修正しました。

project.godot
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
;   [section] ; section goes between []
;   param=value ; assign values to parameters

config_version=5

[application]

config/name="simple-pong"
run/main_scene="res://Main.tscn"

[display]

window/size/viewport_width=360
window/size/viewport_height=720
window/size/resizable=false

[input]

move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"echo":false,"script":null)
]
}

[rendering]

renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"

動作確認と修正

実装完了後、 Godot エディタで F5 キーを押しゲームを起動しました。

simple-pingpong 初回実装

動作には下記のような問題がありました。

  • CPU がボールを打ち返すたびに前進する
  • ボールが壁にくっついたまま離れなくなる
  • ゲームに負けてもゲームが終了しない
  • ボールのスピードが遅すぎる

LLM と対話を繰り返し、何度かコード修正のやりとりを行ったのち、上記の問題を解決しました。

simple-pingpong 修正後

修正後のコード

Main.tscn
[gd_scene load_steps=10 format=3 uid="uid://cilbg8jwrfbyr"]

[ext_resource type="Script" uid="uid://efgbiogmat67" path="res://Main.gd" id="1_0hdqx"]
[ext_resource type="Script" uid="uid://d348gu2f10sit" path="res://Player.gd" id="2_1hdqx"]
[ext_resource type="Script" uid="uid://c6m0g0dnrbvff" path="res://CPU.gd" id="3_2hdqx"]
[ext_resource type="Script" uid="uid://tbmexcoveiia" path="res://Ball.gd" id="4_3hdqx"]

[sub_resource type="RectangleShape2D" id="RectangleShape2D_1"]
size = Vector2(80, 10)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_2"]
size = Vector2(80, 10)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_3"]
size = Vector2(16, 16)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_4"]
size = Vector2(20, 720)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_5"]
size = Vector2(360, 20)

[node name="Main" type="Node2D"]
script = ExtResource("1_0hdqx")

[node name="Player" type="CharacterBody2D" parent="."]
position = Vector2(180, 650)
script = ExtResource("2_1hdqx")

[node name="CollisionShape2D" type="CollisionShape2D" parent="Player"]
shape = SubResource("RectangleShape2D_1")

[node name="ColorRect" type="ColorRect" parent="Player"]
offset_left = -40.0
offset_top = -5.0
offset_right = 40.0
offset_bottom = 5.0

[node name="CPU" type="CharacterBody2D" parent="."]
position = Vector2(180, 70)
script = ExtResource("3_2hdqx")

[node name="CollisionShape2D" type="CollisionShape2D" parent="CPU"]
shape = SubResource("RectangleShape2D_2")

[node name="ColorRect" type="ColorRect" parent="CPU"]
offset_left = -40.0
offset_top = -5.0
offset_right = 40.0
offset_bottom = 5.0

[node name="Ball" type="CharacterBody2D" parent="."]
position = Vector2(180, 360)
script = ExtResource("4_3hdqx")

[node name="CollisionShape2D" type="CollisionShape2D" parent="Ball"]
shape = SubResource("RectangleShape2D_3")

[node name="ColorRect" type="ColorRect" parent="Ball"]
offset_left = -8.0
offset_top = -8.0
offset_right = 8.0
offset_bottom = 8.0

[node name="Walls" type="StaticBody2D" parent="."]

[node name="LeftWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(-10, 360)
shape = SubResource("RectangleShape2D_4")

[node name="RightWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(370, 360)
shape = SubResource("RectangleShape2D_4")

[node name="TopWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(180, -10)
shape = SubResource("RectangleShape2D_5")

[node name="BottomWall" type="CollisionShape2D" parent="Walls"]
position = Vector2(180, 730)
shape = SubResource("RectangleShape2D_5")
Main.gd
extends Node2D

# ゲーム設定
const SCREEN_WIDTH = 360
const SCREEN_HEIGHT = 720
const WINNING_SCORE = 5

# スコア
var player_score = 0
var cpu_score = 0
var game_over = false

# ゲームオブジェクトの参照
@onready var player = $Player
@onready var cpu = $CPU
@onready var ball = $Ball

func _ready():
	# 画面サイズの設定
	get_window().size = Vector2i(SCREEN_WIDTH, SCREEN_HEIGHT)
	get_window().position = Vector2i(100, 100)

	# ボールにスコア更新のシグナルを接続
	ball.goal_scored.connect(_on_goal_scored)

	print("Pingpong game started!")
	print("Use Arrow Keys to move your paddle")
	print("First to ", WINNING_SCORE, " points wins!")

func _on_goal_scored(scorer):
	print("Goal scored by: ", scorer)

	if game_over:
		print("Game is already over, ignoring goal")
		return

	if scorer == "player":
		player_score += 1
		print("Player scores! Score: Player ", player_score, " - CPU ", cpu_score)
	elif scorer == "cpu":
		cpu_score += 1
		print("CPU scores! Score: Player ", player_score, " - CPU ", cpu_score)

	# ゲーム終了判定
	if player_score >= WINNING_SCORE:
		print("=== PLAYER WINS! ===")
		_end_game()
	elif cpu_score >= WINNING_SCORE:
		print("=== CPU WINS! ===")
		_end_game()

func _end_game():
	game_over = true
	# ボールを停止
	ball.velocity = Vector2.ZERO
	ball.is_serving = true

	print("Game Over! Press SPACE to restart or ESC to quit")

func _reset_game():
	print("Resetting game...")

	# ゲーム状態リセット
	game_over = false
	player_score = 0
	cpu_score = 0

	# オブジェクト位置リセット
	player.position = Vector2(180, 650)
	cpu.position = Vector2(180, 70)
	ball.reset_position()

	print("Game reset! New game started.")

func _input(event):
	# ESC キーでゲーム終了
	if event.is_action_pressed("ui_cancel"):
		print("Quitting game...")
		get_tree().quit()

	# スペースキーでリスタート
	if event.is_action_pressed("ui_accept"):
		if game_over:
			_reset_game()
		else:
			print("Current score: Player ", player_score, " - CPU ", cpu_score)

	# R キーでリスタート (デバッグ用)
	if Input.is_key_pressed(KEY_R):
		_reset_game()

# デバッグ用の情報表示
func _process(delta):
	if Input.is_action_just_pressed("ui_select"):  # Tab キー
		print("Ball position: ", ball.position)
		print("Ball velocity: ", ball.velocity)
		print("Game over: ", game_over)
		print("Is serving: ", ball.is_serving)
Player.gd
extends CharacterBody2D

# パドルの設定
const SPEED = 300.0
const PADDLE_WIDTH = 80.0
const SCREEN_WIDTH = 360

# ゲームオブジェクトの参照
@onready var collision_shape = $CollisionShape2D

func _ready():
	# パドルのコリジョンシェイプを設定
	var shape = RectangleShape2D.new()
	shape.size = Vector2(PADDLE_WIDTH, 10)
	collision_shape.shape = shape

func _physics_process(delta):
	# 入力の取得
	var input_direction = 0

	# キーボード入力の処理
	if Input.is_action_pressed("ui_left"):
		input_direction = -1
	elif Input.is_action_pressed("ui_right"):
		input_direction = 1

	# 位置を直接更新
	if input_direction != 0:
		position.x += input_direction * SPEED * delta

	# 画面境界での制限
	_clamp_to_screen()

	# Y 座標を固定
	position.y = 650

func _clamp_to_screen():
	# パドルが画面外に出ないよう制限
	var half_width = PADDLE_WIDTH / 2
	position.x = clamp(position.x, half_width, SCREEN_WIDTH - half_width)
CPU.gd
extends CharacterBody2D

# CPU パドルの設定
const SPEED = 220.0
const PADDLE_WIDTH = 80.0
const SCREEN_WIDTH = 360
const REACTION_ZONE = 400.0  # この Y 座標より上でボールに反応

# AI の設定
const PREDICTION_FACTOR = 0.7  # ボール予測の精度
const DEAD_ZONE = 15.0  # この範囲内では動かない
const CENTER_RETURN_SPEED = 0.4  # 中央復帰の速度係数

# ゲームオブジェクトの参照
@onready var collision_shape = $CollisionShape2D
var ball_node: Node2D
var target_x = 0.0

func _ready():
	# パドルのコリジョンシェイプを設定
	var shape = RectangleShape2D.new()
	shape.size = Vector2(PADDLE_WIDTH, 10)
	collision_shape.shape = shape

	# ボールノードの参照を取得
	ball_node = get_parent().get_node("Ball")
	target_x = SCREEN_WIDTH / 2  # 初期目標は中央

func _physics_process(delta):
	if not ball_node:
		return

	# ボールがサーブ中の場合は中央で待機
	if ball_node.is_serving:
		_move_to_center(delta)
	else:
		# ボールの状態を取得
		var ball_position = ball_node.position
		var ball_direction = ball_node.ball_direction
		var ball_speed = ball_node.current_speed

		# ボールが CPU 側に向かっている場合のみ積極的に反応
		if ball_position.y < REACTION_ZONE and ball_direction.y < 0:
			_track_ball(ball_position, ball_direction, ball_speed, delta)
		else:
			# ボールが遠い場合は中央に戻る
			_move_to_center(delta)

	# 実際の移動実行
	_execute_movement(delta)

	# 画面境界での制限
	_clamp_to_screen()

	# Y 座標を固定(前進を防ぐ)
	position.y = 70

func _track_ball(ball_pos: Vector2, ball_dir: Vector2, ball_speed: float, delta: float):
	# ボールの予測位置を計算
	var time_to_cpu = 0.0
	if ball_dir.y != 0:
		time_to_cpu = abs((position.y - ball_pos.y) / (ball_dir.y * ball_speed))

	# 予測位置を計算
	var predicted_x = ball_pos.x + ball_dir.x * ball_speed * time_to_cpu * PREDICTION_FACTOR

	# 画面境界内に制限
	predicted_x = clamp(predicted_x, PADDLE_WIDTH/2, SCREEN_WIDTH - PADDLE_WIDTH/2)

	target_x = predicted_x

	# デバッグ情報
	if randf() < 0.01:  # 1% の確率で出力(スパム防止)
		print("CPU tracking - Ball pos: ", ball_pos, " Predicted: ", predicted_x, " Target: ", target_x)

func _move_to_center(delta: float):
	# 画面中央を目標に設定
	target_x = SCREEN_WIDTH / 2

func _execute_movement(delta: float):
	# 現在位置と目標位置の差を計算
	var distance = target_x - position.x

	# デッドゾーン内では動かない
	if abs(distance) < DEAD_ZONE:
		return

	# 移動方向と速度を決定
	var direction = sign(distance)
	var move_speed = SPEED

	# 中央復帰時は少し遅く
	if target_x == SCREEN_WIDTH / 2:
		move_speed *= CENTER_RETURN_SPEED

	# 位置を直接更新(物理エンジンを使わない)
	position.x += direction * move_speed * delta

func _clamp_to_screen():
	# パドルが画面外に出ないよう制限
	var half_width = PADDLE_WIDTH / 2
	position.x = clamp(position.x, half_width, SCREEN_WIDTH - half_width)

# デバッグ用
func _input(event):
	if event.is_action_pressed("ui_select"):  # Tab キー
		if ball_node:
			print("CPU Debug:")
			print("  Position: ", position.x)
			print("  Target: ", target_x)
			print("  Ball pos: ", ball_node.position)
			print("  Ball dir: ", ball_node.ball_direction)
			print("  Ball serving: ", ball_node.is_serving)
Ball.gd
extends CharacterBody2D

# ボールの設定
const INITIAL_SPEED = 350.0
const MAX_SPEED = 500.0
const SPEED_INCREMENT = 25.0
const BALL_SIZE = 16.0

# 画面設定
const SCREEN_WIDTH = 360
const SCREEN_HEIGHT = 720

# ゲーム状態
var current_speed = INITIAL_SPEED
var ball_direction = Vector2.ZERO
var is_serving = false

# シグナル
signal goal_scored(scorer)

# ゲームオブジェクトの参照
@onready var collision_shape = $CollisionShape2D

func _ready():
	# ボールのコリジョンシェイプを設定
	var shape = RectangleShape2D.new()
	shape.size = Vector2(BALL_SIZE, BALL_SIZE)
	collision_shape.shape = shape

	# 初期サーブ
	_serve()

func _physics_process(delta):
	if is_serving:
		return

	# 手動でボールを移動
	position += ball_direction * current_speed * delta

	# 境界チェック
	_check_boundaries()

	# パドルとの衝突チェック
	_check_paddle_collisions()

func _serve():
	is_serving = true
	current_speed = INITIAL_SPEED
	position = Vector2(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)

	# 1秒後にサーブ実行
	await get_tree().create_timer(1.0).timeout

	# ランダムな方向でサーブ
	var angle = randf_range(-PI/6, PI/6)  # -30度から30度
	if randf() > 0.5:
		angle += PI  # 50% の確率で下向き

	ball_direction = Vector2(cos(angle), sin(angle))
	is_serving = false

	print("Ball served! Direction: ", ball_direction)

func _check_boundaries():
	# 左右の壁での反射
	if position.x <= BALL_SIZE / 2:
		position.x = BALL_SIZE / 2
		ball_direction.x = abs(ball_direction.x)  # 右向きに
		print("Left wall bounce")
	elif position.x >= SCREEN_WIDTH - BALL_SIZE / 2:
		position.x = SCREEN_WIDTH - BALL_SIZE / 2
		ball_direction.x = -abs(ball_direction.x)  # 左向きに
		print("Right wall bounce")

	# ゴール判定
	if position.y <= -BALL_SIZE:
		print("Player scores! Ball Y: ", position.y)
		goal_scored.emit("player")
		_serve()
	elif position.y >= SCREEN_HEIGHT + BALL_SIZE:
		print("CPU scores! Ball Y: ", position.y)
		goal_scored.emit("cpu")
		_serve()

func _check_paddle_collisions():
	# プレイヤーパドルとの衝突
	var player = get_parent().get_node("Player")
	if _is_colliding_with_paddle(player):
		_handle_paddle_hit(player)

	# CPU パドルとの衝突
	var cpu = get_parent().get_node("CPU")
	if _is_colliding_with_paddle(cpu):
		_handle_paddle_hit(cpu)

func _is_colliding_with_paddle(paddle) -> bool:
	var paddle_pos = paddle.position
	var paddle_size = Vector2(80, 10)  # パドルのサイズ

	# 簡単な矩形衝突判定
	return (position.x + BALL_SIZE/2 > paddle_pos.x - paddle_size.x/2 and
			position.x - BALL_SIZE/2 < paddle_pos.x + paddle_size.x/2 and
			position.y + BALL_SIZE/2 > paddle_pos.y - paddle_size.y/2 and
			position.y - BALL_SIZE/2 < paddle_pos.y + paddle_size.y/2)

func _handle_paddle_hit(paddle):
	# パドルの中心からの相対位置を計算
	var relative_x = (position.x - paddle.position.x) / 40.0  # 正規化
	relative_x = clamp(relative_x, -1.0, 1.0)

	# 反射角度を計算
	var bounce_angle = relative_x * PI/4  # 最大45度

	# 速度を増加
	current_speed = min(current_speed + SPEED_INCREMENT, MAX_SPEED)

	# 新しい方向を設定
	if paddle.name == "Player":
		# プレイヤーパドル - 上向きに反射
		ball_direction = Vector2(sin(bounce_angle), -cos(bounce_angle))
		position.y = paddle.position.y - 10  # パドルから離す
	else:
		# CPU パドル - 下向きに反射
		ball_direction = Vector2(sin(bounce_angle), cos(bounce_angle))
		position.y = paddle.position.y + 10  # パドルから離す

	print("Paddle hit! Speed: ", current_speed, " Direction: ", ball_direction)

func reset_position():
	_serve()

まとめ

今回のピンポンゲーム開発を通し、 Godot のテキストベースでの開発アプローチが LLM との協働に非常に適していることを体験できました。シーンファイルからスクリプトまですべてをテキストエディタで完結できるため、 LLM が生成したコードをそのまま活用でき、開発効率の向上が期待できます。UI やエフェクトなども同様の手法で拡張可能であるため、より本格的なゲーム開発への応用も可能でしょう。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.