20秒的あてを完成させる|Godotで学ぶ入力とオブジェクト

初心者向け実践開発

※本記事にはアフィリエイトリンクが含まれています。
Amazonのアソシエイトとして、ゲームのタネは適格販売により収入を得ています。
※一部リンクは楽天アフィリエイトを含みます。

▶ この回の完成形を、この場で遊ぶ
20秒で、出た的をクリックして得点を稼ぐゲームです。逃げられる前に tap。空白は無効です。

20秒的あて はじめる でスタート
このブラウザではキャンバスを表示できません。

Godotで作る完成形と同じルールです。はじめる(または画面クリック、R)で開始します。手順は下に続きます。

Godotの完成プロジェクト
ブラウザで遊んでいるものと同じゲームです(はじめる、円の的、得点、20秒、終了、もう一度)。ZIPを解凍し、Godot 4.7(4.3以上)で project.godot を開いて F5。
click_targets.zip をダウンロード

はじめに

「エンジンは入れたけれど、画面のものを自分のクリックで消す、という最初の一手が分からない」——そんな声をよく聞きます。

この記事では、Godot 4.7(執筆時点の安定版は 4.7.2)で、20秒的あてを1本作って完成させます。的は自分で出てきて、短い時間で消えます。出ているあいだにクリックすると1点、時間切れで終了です。自己ベストを更新するために、何度でもやり直せます。

今週身につけること:入力とオブジェクト。 マウスのクリックを「どのオブジェクトが受け取ったか」まで追えるようになります。制限時間と得点は、それがゲームになるための枠です。

完成形と、やらないこと

  • 作るもの: 20秒間、的がランダムに出現する。クリックで1点。逃すと得点は増えない。時間切れで終了、Rでやり直し。
  • やらないこと: 画像素材の作り込み、音、ランキングサーバー、スマホ専用UI。
  • 持ち帰る1アイデア: クリックは「画面全体のイベント」ではなく、Area2D(範囲を持つオブジェクト)が受け取れる。
的が5つ並んだ完成形
的の見た目の例。本番では位置も出現も毎回変わります。
的が2つ消え、残り3個になった画面
クリックした的だけが消えます。空白は無効です。

準備(プロジェクト)

  1. Godot公式ダウンロードから安定版(4.7系)を入れます。まだの方は、サイトの導入記事もどうぞ。
  2. 完成一式から始める場合は、上の ZIP を解凍して project.godot をインポートします。自分で組む場合は次です。プロジェクトマネージャーで「新規プロジェクト」→名前は例として click_targets。レンダラーは初心者なら「互換性」で十分です。
  3. 2Dシーンを開き、ルートを Node2D にして名前を Main にします。保存先は res://main.tscn。プロジェクト設定でメインシーンに指定します。

的オブジェクトを1つ作る

的は「見た目」と「クリックを拾う範囲」をセットで持ちます。Godotではよく、Area2D の子に CollisionShape2D(当たりの形)と Polygon2D(色付きの見た目)を置きます。

クリックは的のArea2Dが受け取り、空白は反応しない図
クリックは画面全体ではなく、的オブジェクトが受け取ります。
  1. 新しいシーンを作り、ルートを Area2D。名前は Target。保存は res://target.tscn
  2. 子ノードを追加:CollisionShape2D。Inspector の Shape に New → CircleShape2D。Radius は 36 程度(見た目の円と揃えます)。
  3. もう1つ子を追加:Polygon2D。Polygon に矩形か円に近い頂点を入れ、色を好みで変更します(例:明るい赤)。見た目の大きさと円の半径がだいたい一致するようにします。
  4. ルートの Area2D で、Input Pickable がオン、Collision → Layer に少なくとも1ビットが入っていることを確認します(初期値のままで通常は問題ありません)。公式ドキュメントでも、ピックには input_pickable と collision layer の両方が必要と書かれています。

スクリプト1:target.gd(Area2D に付ける)

ファイル名:res://target.gdTarget(Area2D)にアタッチします。

extends Area2D

## 消えたことを親(Main)に知らせる
signal cleared

const LIFE := 1.15
const COLORS := [
	Color(0.910, 0.365, 0.298),
	Color(0.941, 0.635, 0.008),
	Color(0.239, 0.733, 0.604),
	Color(0.357, 0.553, 0.937),
	Color(0.780, 0.490, 1.000),
]

var life: float = LIFE

func _ready() -> void:
	_make_circle()
	get_tree().create_timer(LIFE).timeout.connect(queue_free)

func _process(delta: float) -> void:
	life -= delta
	var k: float = maxf(0.35, life / LIFE)
	modulate.a = 0.45 + 0.55 * k
	scale = Vector2.ONE * (0.85 + 0.15 * k)

func _make_circle() -> void:
	var poly := $Polygon2D
	var pts := PackedVector2Array()
	for i in 24:
		var a := TAU * float(i) / 24.0
		pts.append(Vector2(cos(a), sin(a)) * 36.0)
	poly.polygon = pts
	poly.color = COLORS[randi() % COLORS.size()]

func _input_event(_viewport: Viewport, event: InputEvent, _shape_idx: int) -> void:
	if event is InputEventMouseButton \
			and event.pressed \
			and event.button_index == MOUSE_BUTTON_LEFT:
		cleared.emit()
		queue_free()

別ゲームに持ち替えるときの変更点: 左クリック以外にしたいなら MOUSE_BUTTON_RIGHT やキー入力に差し替えます。消す代わりにダメージを与えたいなら queue_free() の前に自分のHPを減らす処理を挟みます。

なぜ _input_event か:画面全体の _input でマウス座標と的の位置を毎回計算するより、「このオブジェクトの形の上で起きた入力」だけを公式が渡してくれます(CollisionObject2D の仮想メソッド/input_event シグナル)。

メインシーンで出現・得点・制限時間を回す

  1. main.tscnMain の下に、盤面色の Polygon2D(名前 Board、画面のバーより下)と、空の Node2D(名前 Targets)を置きます。的はあらかじめ置かず、コードから出します。BoardCanvasLayer の色付き矩形にすると的が隠れます。
  2. CanvasLayer を追加し、名前を UI にします。子に上部バー(HBoxContainerBar)を置き、その中に LabelRemainingLabel)と黄色い ButtonStartButton、テキスト「はじめる」)。盤面の上に ControlOverlay)と2つの Label(Title / Sub)を置きます。完成ZIPではこのUIまで入っています。
  3. main.gd を付けます。的シーンは preload("res://target.tscn") で読むので、Inspector へのドラッグは不要です。
Mainの下にUIとTargetsがあり、的はArea2Dというシーン構造の図
今回のシーン構造。Main の下に UI と Targets を分けます。

スクリプト2:main.gd(Main に付ける)

ファイル名:res://main.gd。ルートの Main にアタッチします。

extends Node2D

const LIMIT := 20.0
const SPAWN_EVERY := 0.55
const MAX_ON_SCREEN := 3
const BOARD := Vector2(720, 450)
const BOARD_TOP := 56.0

@onready var remaining_label: Label = $UI/Bar/RemainingLabel
@onready var start_button: Button = $UI/Bar/StartButton
@onready var overlay_title: Label = $UI/Overlay/Title
@onready var overlay_sub: Label = $UI/Overlay/Sub
@onready var overlay: Control = $UI/Overlay
@onready var targets: Node2D = $Targets
var target_scene: PackedScene = preload("res://target.tscn")

var score: int = 0
var best: int = 0
var time_left: float = LIMIT
var spawn_acc: float = 0.0
var state: String = "ready"

func _ready() -> void:
	start_button.pressed.connect(_start_game)
	overlay.gui_input.connect(_on_board_gui_input)
	_show_ready()

func _process(delta: float) -> void:
	if state != "play":
		return
	time_left -= delta
	spawn_acc += delta
	if spawn_acc >= SPAWN_EVERY:
		spawn_acc = 0.0
		_spawn()
	if time_left <= 0.0:
		time_left = 0.0
		_end_game()
		return
	_refresh_hud()

func _start_game() -> void:
	state = "play"
	score = 0
	time_left = LIMIT
	spawn_acc = 0.0
	for child in targets.get_children():
		child.queue_free()
	overlay.visible = false
	overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
	start_button.text = "やり直す(R)"
	_spawn()
	_spawn()
	_refresh_hud()

func _end_game() -> void:
	state = "over"
	for child in targets.get_children():
		child.queue_free()
	if score > best:
		best = score
	remaining_label.text = "終了  得点 %d  自己ベスト %d" % [score, best]
	start_button.text = "もう一度(R)"
	overlay_title.text = "得点  %d" % score
	overlay_sub.text = "もう一度でベストを更新"
	overlay.mouse_filter = Control.MOUSE_FILTER_STOP
	overlay.visible = true

func _show_ready() -> void:
	state = "ready"
	remaining_label.text = "20秒的あて はじめる でスタート"
	start_button.text = "はじめる"
	overlay_title.text = "20秒で、出た的をクリック"
	overlay_sub.text = "逃げられる前に tap。空白は無効"
	overlay.mouse_filter = Control.MOUSE_FILTER_STOP
	overlay.visible = true

func _spawn() -> void:
	if targets.get_child_count() >= MAX_ON_SCREEN:
		return
	var t := target_scene.instantiate()
	t.position = Vector2(
		randf_range(80.0, BOARD.x - 80.0),
		randf_range(80.0, BOARD.y - 80.0) + BOARD_TOP
	)
	if t.has_signal("cleared"):
		t.cleared.connect(_on_hit)
	targets.add_child(t)

func _on_hit() -> void:
	if state == "play":
		score += 1
		_refresh_hud()

func _refresh_hud() -> void:
	remaining_label.text = "得点 %d   残り %d 秒" % [score, ceili(time_left)]

func _on_board_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		if state == "ready" or state == "over":
			_start_game()

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventKey and event.pressed and not event.echo:
		if event.keycode == KEY_R:
			_start_game()

preload("res://target.tscn") は、的シーンを何度でも複製するための読み込みです。パス $UI/Bar/RemainingLabel$UI/Bar/StartButton$UI/Overlay がシーンと一致しているか確認してください。完成ZIPではこのUIまで入っています。

別ゲームへのリネーム例: TargetsMolesclearedwhacked。出現と得点の枠はそのまま使えます。

Playして確認すること

  1. F5(または Play)で実行します。最初は止まります。はじめる(または盤面クリック、R)でスタートします。
  2. 的が次々と出ます。的の上で左クリックすると1点入ります。
  3. クリックしない的は約1.15秒で消えます。空白をクリックしても得点は増えません。
  4. 20秒で「終了」と自己ベストが出ます。もう一度または R でやり直しです。

「動けばOK」ではなく、次を確認してください。空白をクリックしても得点が増えないこと。これが「オブジェクトが入力を受け取っている」証拠です。

的がすべて消え、クリアと表示された画面
時間切れで終了。Rキーでもう一度遊べます。

よくあるつまづき(1つ)

症状: 得点と残り時間は動くのに、盤面に的が見えない。

気づき方: CanvasLayer の子に盤面いっぱいの ColorRect があると、その下の Node2D(的)が隠れます。盤面の色は Main 直下の Polygon2D(名前 Board)に置き、UIレイヤーには上部バーと開始オーバーレイだけを置きます。

症状: Playしてもクリックしても的が消えない。

気づき方: シーンドックで CollisionShape2D を選び、Inspector の Shape が空(<empty>)になっていないか見ます。形が無いとピックできません。あわせて Area2D の Input Pickable がオフ、または Collision Layer がすべてオフ、になっていないかも確認します。

できたこと/身についたこと

  • Area2D+CollisionShape2D で「クリックできるオブジェクト」を表現できた。
  • _input_event でマウスボタンを判定し、queue_free() で消せるようになった。
  • 親が出現・制限時間・得点を回し、時間切れと もう一度(R)によるやり直しまでつながった。

使い回しメモ

  • スニペット: target.gd_input_event 一式。
  • エンコードしている考え: 「入力はオブジェクトが拾う」。
  • 次の使い道: シューティングの敵をクリック/タップで撃つ、メニューの透明なボタン領域、マップ上の建物を選ぶ。

次の自分のゲームで使うなら

ジャンルが違っても、「画面のどこかをクリックした」ではなく「どのノードの形の上か」を先に決めると同じ詰まりを避けられます。次は同じ的あてを Unity で作り、コンポーネントの分け方の違いを見ると理解が定着します。

次に足してよい一言

的が消えるたびに短い音を1つ足す、または連続ヒットで得点が伸びる、くらいが次の小さな一歩です。

関連商品(PR)

この回はクリック操作が中心なので、続きの学習用の本と、操作しやすいマウス/パッドだけ短く挙げます。価格は店舗で確認してください。

参考

クラスやメソッド、Godotのバージョンは、次の公式ページで確認しています。

コメント

タイトルとURLをコピーしました