
LLM との協働開発に適したゲームエンジン Godot でテキストベース開発を試してみた
はじめに
本記事では、 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 キーを押しゲームを起動しました。
動作には下記のような問題がありました。
- CPU がボールを打ち返すたびに前進する
- ボールが壁にくっついたまま離れなくなる
- ゲームに負けてもゲームが終了しない
- ボールのスピードが遅すぎる
LLM と対話を繰り返し、何度かコード修正のやりとりを行ったのち、上記の問題を解決しました。
修正後のコード
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 やエフェクトなども同様の手法で拡張可能であるため、より本格的なゲーム開発への応用も可能でしょう。