実践ガイド

1局のカード対戦を完成させる|Godotで学ぶデータと表示の分け方

※本記事にはアフィリエイトリンクが含まれています。

Amazonのアソシエイトとして、ゲームのタネは適格販売により収入を得ています。

▶ この回の完成形を、この場で遊ぶ

ブラウザ版と同じ核(手札3枚・HP・カードをデータとして持ち、ボタンはデータから作り直す)を、Godot 4.7 向けに1本にまとめたミニゲームです。相手のHPを0にすればクリア、自分のHPが0で失敗です。

1局のカード対戦 はじめるでスタート

あなた HP 20

相手 HP 20
手札のボタンは「データ」から作り直します。

手札(データ → ボタン)

1局のカード対戦

相手のHPを0にすれば勝ち。自分のHPが0で負け。

記事中の GDScript・ダウンロード ZIP と同じルールです。はじめる(または画面、R)で開始。手札のボタンを1枚押すと自分の番→相手の番です。

Godot の完成プロジェクト

上で遊んでいるものと同じゲームです。ZIP を解凍し、Godot 4.7(執筆時点の安定版は 4.7.2)で project.godot を開いて F5。

card_duel.zip をダウンロード

はじめに

「カードの名前は画面に出ている。でも、ダメージの数字はどこから読んでいるの?」——そんな疑問向けです。

この記事では、Godot 4.7 で1局のカード対戦を1本の完成ゲームにします。自分の番の開始時には手札が3枚になります。攻撃・防御・回復のカードを1枚選ぶと効果が入り、相手も1枚使います。相手のHPが0以下でクリア、自分のHPが0以下で失敗です。R(またはボタン)でやり直せます。

今週身につけること:データと表示を分ける

カードの種類や数値は Dictionary(辞書)にまとめ、カードごとに配列へ入れ、画面の Button はそれを読んで作り直します。勝敗やダメージの計算は、ボタンの文字ではなくデータ側の kind と value だけを見ます。

自分の番/相手の番という「状態」の考え方は、第4回(Unityの1戦闘ターン制)と同じです。今回はカードをデータとして持つところに焦点を当てます。

作るもの/作らないもの

  • 作る:HP20対20、手札3、山札(攻撃3・防御2・回復2)、相手の簡易ターン、勝敗画面、やり直し
  • 作らない:デッキ構築UI、アニメ、セーブ、ネットワーク対戦、多数のカード種

完成イメージ

開始画面のイメージ

手札3枚のプレイ中

クリア画面 失敗画面

データと表示の分け方

カードデータと手札UIの関係図

公式の Resources チュートリアルでも、「ノードは機能、リソース(データ)は入れ物」と説明されています。今回は入門向けに Dictionary の配列で同じ考え方を体験します。慣れたら Resource クラスへ移しても構いません。

シーン構造

main.tscn のシーン構造

背景の ColorRect は Main(Node2D)の直下に置きます。UI 用の CanvasLayer の上に広い塗りを置くと、2D の描画がその下に隠れてしまいます(この連載で何度も踏んだ落とし穴です)。

準備

  1. Godot 公式ダウンロードから安定版 4.7.2 を入れる(Windows/macOS も同じページ系統から)
  2. card_duel.zip を解凍し、project.godot を開く
  3. F5 でプレイ。記事のスクリプトは同じ考え方を示す抜粋で、そのまま差し替えるコードではありません

入力の流れ

1ターンの入力の流れ

手札の Button を押すと、pressed シグナルに接続する際、bind(card) でそのボタンに対応する Dictionary を渡します。表示用の文字は Label/Button の text に書きますが、ロジック側はそれを読みません。

スクリプト① カードのデータ定義(card_defs.gd)

ファイル名:card_defs.gd(class_name CardDefs)。アタッチ先は不要です(定義だけ)。別ゲームにするときは catalog() と山札の枚数を変えます。新しい種類の効果には、表示や効果の処理も追加します。

class_name CardDefs
extends RefCounted
## カードのデータ定義だけを持つ。ボタンや Label には触れない。
## 既存の効果を使うカードの種類や値を定義する。

const START_HP := 20
const HAND_SIZE := 3

## id / name / kind / value が1枚分の「データ」
## kind: attack=相手ダメージ / guard=次の被ダメ軽減 / heal=自分回復
static func catalog() -> Array:
	return [
		{"id": "atk5", "name": "攻撃", "kind": "attack", "value": 5},
		{"id": "grd3", "name": "防御", "kind": "guard", "value": 3},
		{"id": "heal4", "name": "回復", "kind": "heal", "value": 4},
	]

## 山札の枚数構成(同じ定義を複数枚入れる)
static func build_deck() -> Array:
	var defs := {}
	for c in catalog():
		defs[c["id"]] = c
	var ids: Array = [
		"atk5", "atk5", "atk5",
		"grd3", "grd3",
		"heal4", "heal4",
	]
	var deck: Array = []
	for id in ids:
		deck.append(defs[id].duplicate())
	return deck

static func label_for(card: Dictionary) -> String:
	var kind: String = card["kind"]
	var v: int = card["value"]
	if kind == "attack":
		return "%s\n相手 -%d" % [card["name"], v]
	if kind == "guard":
		return "%s\n被ダメ -%d" % [card["name"], v]
	return "%s\n自分 +%d" % [card["name"], v]

別ゲーム向けの変えどころ:value の数字、カード種の追加、START_HP と HAND_SIZE。

スクリプト② 手札の表示(hand_ui.gd)

ファイル名:hand_ui.gd。アタッチ先:手札用の HBoxContainer(このプロジェクトでは UI/HandRow)。公式の Array の要素を走査し、古い子は Node.queue_free で消してから作り直します。

extends HBoxContainer
## 手札の「表示」だけ。渡された配列を読んで Button を作り直す。
## ロジック側は Button の文字を読まず、bind した Dictionary を使う。

signal card_chosen(card: Dictionary)

func redraw(hand: Array, interactive: bool) -> void:
	for child in get_children():
		child.queue_free()
	for card in hand:
		var btn := Button.new()
		btn.text = CardDefs.label_for(card)
		btn.custom_minimum_size = Vector2(140, 88)
		btn.disabled = not interactive
		# 押されたとき「データ」を渡す(text は渡さない)
		btn.pressed.connect(_on_pressed.bind(card))
		add_child(btn)

func _on_pressed(card: Dictionary) -> void:
	card_chosen.emit(card)

別ゲーム向けの変えどころ:ボタンサイズ、1行表示/2行表示、クリックではなくドラッグにする場合はシグナル名。

スクリプト③ 効果はデータだけ見る(抜粋)

main.gd の考え方です。ダメージも回復も card["kind"] と card["value"] から決めます。乱数は公式の @GlobalScope.randi、山札は Array の shuffle を使います。

## 抜粋:カード「データ」だけ見て効果を決める(Label.text は読まない)
## main.gd の _apply_card と同じ考え方です。
func apply_card(card: Dictionary, by_player: bool) -> void:
	var kind: String = card["kind"]
	var v: int = int(card["value"])
	if kind == "attack":
		if by_player:
			enemy_hp -= maxi(0, v - enemy_guard)
			enemy_guard = 0
		else:
			player_hp -= maxi(0, v - player_guard)
			player_guard = 0
	elif kind == "guard":
		if by_player:
			player_guard = v
		else:
			enemy_guard = v
	elif kind == "heal":
		if by_player:
			player_hp = mini(START_HP, player_hp + v)
		else:
			enemy_hp = mini(START_HP, enemy_hp + v)

Play して確認すること

  • はじめる後、手札がいつも3枚ある
  • 攻撃で相手HPが減り、回復で自分HPが増える(最大20)
  • 防御のあと相手の攻撃が減る
  • どちらかが0以下でクリア/失敗が出て、やり直せる
  • インスペクタで Button の文字を書き換えても、次の redraw でデータどおりに戻る(=ロジックが文字に依存していない)

よくあるつまずき:手札ボタンを押しても反応しないときは、state が player か、Overlay が前面でクリックを奪っていないかを見ます。

できたこと

  • Godot 4.7 で1局のカード対戦を完成させた
  • カード定義(データ)と手札ボタン(表示)を分けた
  • 勝敗とやり直しまで含めた「遊べる終わり」にした

身についたこと

画面に出ている文字は「見せるため」。ゲームの正しさは、別置きしたデータと数値で決める、という感覚です。カード以外でも、アイテム一覧・スキル一覧・ショップ商品にそのまま使えます。

使い回しメモ

  • snippet:card_defs.gd+hand_ui.redraw
  • アイデア:データ配列 → UIを生成 → 入力はデータ参照
  • 次の用途:クイズの選択肢ボタン、インベントリのアイコン列、ショップの商品リスト

関連商品(PR)

参考

次に足してよい一言

既存の攻撃・防御・回復と同じ効果で数値の違うカードなら、catalog() と山札の構成を変更できます。新しい効果を追加する場合は、label_for() の表示と _apply_card() の処理も追加し、相手が使う場合の動きも確かめます。

← 前回:3Dの1部屋から脱出を完成させる|Unityで学ぶカメラとレイ

→ 次回:連載の続き(公開後にリンクします)

← 記事一覧・検索