『雨まで10秒』をGodotで完成させる|パイプ回転と水の到達判定

プレイ開始画面のモック 初心者向け実践開発

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

▶ この回の完成形を、この場で遊ぶ
ブラウザ版『雨まで10秒』と同じ核(パイプ回転+水路の到達)を、Godot 4.7 向けに1本にまとめたミニゲームです。10秒以内に水路をつなぎ、雨が降ったときに植物へ水が届くとクリアです。

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

記事中の GDScript・ダウンロード ZIP と同じルールです。はじめる(または画面クリック、R)で開始。パイプをクリックで回転。

Godot の完成プロジェクト
上で遊んでいるものと同じゲームです。ZIP を解凍し、Godot 4.7(執筆時点の安定版は 4.7.2)で project.godot を開いて F5。
ame_made_10byo.zip をダウンロード

はじめに

「パイプは回った。でも、水が本当につながっているかは、どう判定するの?」——そんな疑問向けです。

この記事では、Godot 4.7 でブラウザ企画の『雨まで10秒』をエンジン向けに移植し、1本の完成ゲームにします。雨が降るまでの10秒間に庭の水路をつなぎ、植物に水を届けます。時間切れで水が流れ、入口から植物まで届いていればクリア、途切れていれば失敗です。R(またはボタン)で何度でもやり直せます。

今週身につけること:接続データの回転と、入口からの到達判定。 見た目の回転と内部の接続マスクを同じ rotation から作ります。隣マスは、共有辺の両側が開いているときだけつながります。幅優先探索(BFS)で入口から広げ、植物に届いたかを見ます。制限時間とクリア/失敗は、それがゲームになるための枠です。

ブラウザ版の連載(企画→接続→完成)で遊びの核はすでに検証済みです。この回は、そこで約束したエンジン移植の選択肢・その1(Godot)です。TypeScript の第④回ではありません。

完成形と、やらないこと

  • 作るもの: 5×5 盤面。I / L / T パイプをクリックで90度回転。入口と植物は固定。10秒後に水が流れ、BFS で到達判定。クリア/失敗とやり直し。Godot ZIP と、同じルールのイン記事プレイ。
  • やらないこと: 岩ステージ7本、成長段階の花、TypeScript 書き直し、Unity 併記、音、ランキング。
  • 持ち帰る1アイデア: 「見た目の角度」と「つながっているか」を別々に持たない。同じ回転値からマスクを作り、両側オープンの辺だけを辿る。
雨まで10秒の開始画面。5×5のパイプ盤と入口・植物
完成形の開始画面(イン記事プレイ/ZIP と同じルール)。
L字の回転、両側オープン、入口からのBFSの図
今週の核:回転マスク・両側オープン・BFS。

準備(Godot 4.7)

  1. Godot 公式ダウンロードから 4.7(安定版 4.7.2)を入れます。
  2. 上の ame_made_10byo.zip を解凍し、project.godot を開きます。自分で組む場合は 2D の新規プロジェクトでも構いません。
  3. メインシーンは main.tscn(ルートは Node2D)です。

初心者がハマりやすい点: 盤面の塗りつぶしを CanvasLayer の全面 ColorRect に置くと、2D のパイプが HUD の下に隠れて見えなくなります。第1回の的あてでも起きた失敗です。盤は Node2D(この回では BoardDraw)側に置き、HUD は上部バーとラベルだけを CanvasLayer にします。

Main Node2D、BoardDraw、UI CanvasLayer のシーン構造
入力・シーン構造。盤は Node2D、HUD は CanvasLayer(全面塗りは禁止)。

盤面データと接続マスク

各マスは種類(I / L / T / 入口 / 植物 / 岩)と rot(0〜3)を持ちます。基準の開口ビット(北=1, 東=2, 南=4, 西=8)を、時計回りに rot 回だけ回したものが「今の開口」です。描画も同じマスクを線として引くので、見た目と判定がずれません。

スクリプト1:pipe_board.gd(接続とBFS)

ファイル名:pipe_board.gd。シーンに直接付けず、class_name PipeBoard として他スクリプトから呼び出します。

extends RefCounted
class_name PipeBoard
## パイプ盤面の接続マスクと入口からの到達判定(BFS)。
## 見た目の回転と内部マスクは同じ rotation から作る。

# 方向ビット: N=1 E=2 S=4 W=8
const N := 1
const E := 2
const S := 4
const W := 8
const OPP := {N: S, E: W, S: N, W: E}
const DELTA := {N: Vector2i(0, -1), E: Vector2i(1, 0), S: Vector2i(0, 1), W: Vector2i(-1, 0)}

# shape 基準(rotation=0)の開口。I=縦、L=下+右、T=左+下+右
const BASE := {
	"I": N | S,
	"L": S | E,
	"T": W | S | E,
	"IN": S,   # 入口:下へ出す(固定)
	"PLANT": N # 植物:上から受ける(固定)
}

static func rotate_mask(mask: int, rot: int) -> int:
	var r := ((rot % 4) + 4) % 4
	var out := 0
	for _i in r:
		# 時計回り: N->E->S->W->N
		var n := 0
		if mask & N: n |= E
		if mask & E: n |= S
		if mask & S: n |= W
		if mask & W: n |= N
		mask = n
	return mask

static func openings(kind: String, rot: int) -> int:
	var base: int = BASE.get(kind, 0)
	if kind == "IN" or kind == "PLANT":
		return base # 固定マスは回さない
	return rotate_mask(base, rot)

## cells: Array of Dictionary {kind, rot, fixed}
## size: Vector2i  入口・植物の座標を返す
static func find_special(cells: Array, size: Vector2i) -> Dictionary:
	var inlet := Vector2i(-1, -1)
	var plant := Vector2i(-1, -1)
	for y in size.y:
		for x in size.x:
			var c: Dictionary = cells[y * size.x + x]
			if c.kind == "IN":
				inlet = Vector2i(x, y)
			elif c.kind == "PLANT":
				plant = Vector2i(x, y)
	return {"inlet": inlet, "plant": plant}

## 両側が開いている辺だけ通る BFS。到達セルの座標配列を返す。
static func reach_from_inlet(cells: Array, size: Vector2i) -> Array[Vector2i]:
	var special := find_special(cells, size)
	var start: Vector2i = special.inlet
	var reached: Array[Vector2i] = []
	if start.x < 0:
		return reached
	var visited := {}
	var q: Array[Vector2i] = [start]
	visited[start] = true
	while not q.is_empty():
		var cur: Vector2i = q.pop_front()
		reached.append(cur)
		var ci: int = cur.y * size.x + cur.x
		var open_a: int = openings(cells[ci].kind, int(cells[ci].rot))
		for dir in [N, E, S, W]:
			if (open_a & dir) == 0:
				continue
			var nxt: Vector2i = cur + DELTA[dir]
			if nxt.x < 0 or nxt.y < 0 or nxt.x >= size.x or nxt.y >= size.y:
				continue
			if visited.has(nxt):
				continue
			var ni: int = nxt.y * size.x + nxt.x
			var open_b: int = openings(cells[ni].kind, int(cells[ni].rot))
			# 隣マスは反対方向も開いているときだけつながる
			if (open_b & OPP[dir]) == 0:
				continue
			visited[nxt] = true
			q.append(nxt)
	return reached

static func water_reaches_plant(cells: Array, size: Vector2i) -> bool:
	var special := find_special(cells, size)
	var plant: Vector2i = special.plant
	if plant.x < 0:
		return false
	for p in reach_from_inlet(cells, size):
		if p == plant:
			return true
	return false

別ゲームに持ち替えるときの変更点: BASE に十字パイプを足す、入口を複数にする、ゴールを「スイッチ」に変える、など。BFS の枠組みはそのまま使えます。

なぜ両側オープンか:片側だけ開いていても水路はつながっていない、というルールをデータで表現するためです。隣マスの反対方向ビットも見て初めて辺を通します。

プレイ中。カウントダウンとパイプ盤面
プレイ中。10秒以内に回転を合わせます。

クリックで回転する

入力は Godot 4.7 の InputEventMouseButton_unhandled_input で受け取ります(公式の InputEvent 解説どおり、GUI が先に処理したあとにゲーム側へ届きます)。左ボタンが押された座標からマスを求め、rot = (rot + 1) % 4 とします。

スクリプト2:クリック回転(main.gd の抜粋)

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

extends Node2D
## クリックでパイプを1段階回転(抜粋)。main.gd に含めています。

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		_try_rotate_at(event.position)

func _try_rotate_at(pos: Vector2) -> void:
	var local := pos - BOARD_ORIGIN
	var gx := int(floor(local.x / CELL))
	var gy := int(floor(local.y / CELL))
	if gx < 0 or gy < 0 or gx >= COLS or gy >= ROWS:
		return
	var i := gy * COLS + gx
	if cells[i].fixed:
		return
	cells[i].rot = (int(cells[i].rot) + 1) % 4
	board_draw.queue_redraw()

別ゲームに持ち替えるときの変更点: マスではなくカードを回す、右クリックで逆回転、など。座標→インデックスの変換だけ差し替えれば足ります。

雨が降ったら到達判定

制限時間は _process で減らします(短いカウントなので Timer ノードでもよいですが、この完成 ZIP は毎フレーム減算です)。0 になったら PipeBoard.reach_from_inlet を呼び、植物マスが含まれていればクリアです。

スクリプト3:雨の判定(main.gd の抜粋)

func _rain_and_judge() -> void:
	var size := Vector2i(COLS, ROWS)
	var reached: Array = PipeBoard.reach_from_inlet(cells, size)
	watered.clear()
	for p in reached:
		watered[p] = true
	board_draw.queue_redraw()
	var ok := PipeBoard.water_reaches_plant(cells, size)
	if ok:
		state = "clear"
		# HUD / オーバーレイ更新…
	else:
		state = "fail"
		# 失敗表示…

別ゲームに持ち替えるときの変更点: 時間切れではなく「決定ボタン」で流す、複数植物のうち過半数、など。判定関数の戻り値の使い方だけ変えれば応用できます。

クリア画面。水が届いたパイプが強調される
クリア例。入口から植物まで水が届いています。

エディタでの組み立て手順

  1. 新規プロジェクト(または ZIP)で main.tscn を開きます。
  2. ルート Main(Node2D)に main.gd を付けます。
  3. 子に BoardBg(Polygon2D・背景)、BoardDraw(Node2D + board_draw.gd)を置きます。ここがプレイフィールドです。
  4. 子に UI(CanvasLayer)を作り、上部だけ ColorRectLabelButton、結果用 Overlay を置きます。盤全体を覆う ColorRect は作らないでください。
  5. pipe_board.gd をプロジェクトに置き、F5 で Play します。

Play で確認すること

  • パイプが表示され、クリックで90度ずつ回る。
  • 入口・植物・岩は回らない。
  • 10秒で水が流れ、つながっていればクリア、切れていれば失敗。
  • R または「もう一度」で再開できる。
  • (学習の確認)途中のマスをわざとずらすと、水が途中で止まる。マスクと BFS が効いている証拠です。

できたこと

  • Godot 4.7 で『雨まで10秒』のミニ版を1本完成させた。
  • イン記事プレイ・記事の脚本・ZIP が同じルールになった。

身についたこと

  • 接続マスクを rotation から作る考え方。
  • 共有辺の両側オープン判定。
  • 入口からの BFS 到達判定。
  • プレイフィールドを CanvasLayer 全面で隠さない配置。

使い回しメモ

  • スニペット: pipe_board.gdopeningsreach_from_inlet
  • アイデア: 見た目と接続を同じデータから作る。
  • 次の用途: 配線パズル、配管の漏水チェック、ダンジョンの扉が開く条件(スイッチが電源に届いているか)。

関連商品(PR)

パイプをクリックして回す回なので、Godot の入門書と、クリック操作がしやすいマウスを挙げます。

  • ゆるっとはじめる Godot Engine ゲームプログラミング(杉山悠真) Amazonで見る
    ※本リンクはアフィリエイトリンクです。
  • Logicool G304 ワイヤレスゲーミングマウス(クリック操作の練習にも扱いやすい定番) Amazonで見る
    ※本リンクはアフィリエイトリンクです。

参考

次に足してよい一言

ステージを2枚にする、クリアまでのクリック数を表示する、入口を左右どちらからも選べるようにする——どれか1つで十分「自分のアレンジ」になります。

前回まで: 第1回 Godot 20秒的あて第2回 Unity/Godot 比較
ブラウザ版の企画メモ(参考・TypeScript 第④ではありません):
①企画
②接続
③完成

コメント

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