【Godot4.x】神経衰弱サンプル

神経衰弱のサンプルを作ったのでソースコードと簡単な解説をします。

神経衰弱サンプル

ソースコード

ソースコードは GitHub にアップロードしています。

コードの記述内容の難易度は少し高いです

たいぶややこしい実装方法を採用したので、今回のコードはゲーム内容のシンプルさと比較して難易度が高めです。

Array2: 2次元配列管理クラス

カードゲームなどでは、よくグリッド(格子状)にカードが配置されます。

そのためカードのデータ(配置したカードID)を2次元配列にすると管理がしやすくなるため、Array2 というクラスを実装しています。

Array2の中からよく使いそうな関数を以下に列挙しておきます。

  • init(w:int, h:int): 初期化関数。幅と高さを指定して2次元配列を作る
  • get_v(i:int, j:int): 2次元配列の横と縦の位置を指定して値を取得する
  • set_v(i:int, j:int): 2次元配列の横と縦の位置を指定して値を設定する
  • shuffle(): 値をランダムにシャッフルする
  • foreach(f:Callable): ラムダ式を渡してすべての値に対しての処理を行う

Card: カード

ノードの階層

Cardノードの階層は以下のようになっています。

Cardノードの階層
Card (Area2D)
 +-- Back: 裏側のスプライト
 +-- CollisionShape2D: マウスとの値判定
 +-- Front: 表側のスプライト
 +-- White: 点滅用スプライト
 +-- Label: デバッグ用ラベル

Cardシーンは Area2D でマウスカーソルとの当たり判定を取れるようになっています。

以下のシグナルがマウスカーソルの判定ですね。

## マウスカーソルが入ってきた.
func _on_mouse_entered() -> void:
	_selected = true
	_blink_timer = PI/2/2 # 90度=1.0

## マウスカーソルが出ていった.
func _on_mouse_exited() -> void:
	_selected = false

“Back” ノードが裏側のスプライトで、”Front” ノードが表側のスプライトです。この切り替わりの処理が少しややこしいので次の項で説明します。

カードをめくる処理

カードをめくる処理は sinカーブを使って X方向のスケールを拡大・縮小するという処理で実装しています。

Card.gd_update_flip() でその実装をしています。

## 更新 > ひっくり返す.
## @param is_back_card: 裏のカードかどうか.
## @param rot_rate: 回転の割合 (0.0〜1.0)
func _update_flip(is_back_card:bool, rot_rate:float) -> void:
	# 表・裏のスプライトを非表示.
	_back.visible = false
	_front.visible = false
	
	# 回転するスプライト.
	var spr:Sprite2D = _front
	if is_back_card:
		# 対象は裏のカード.
		spr = _back
	# 表示するスプライトだけ表示する.
	spr.visible = true
	# sinカーブで回転.
	spr.scale.x = 1.0 * sin(PI/2 * rot_rate)	

この関数の最後の “sinカーブで回転” というのがめくったときの回転処理となります。

引数として、”is_back_card” 裏のカードかどうかのフラグと、”rot_rate” 回転の割合を受け取ります。”rot_rate” は 0.0〜1.0 までの値となる前提で、0.0に近づくほど小さくなり、1.0が最大となります。

カードの状態は以下の enum を定義しています。

## 状態.
enum eState {
	BACK, # 裏向き.
	BACK_TO_FRONT1, # 裏から表.
	BACK_TO_FRONT2,
	FRONT, # 表向き.
	FRONT_TO_BACK1, # 表から裏.
	FRONT_TO_BACK2,

	VANISH, # 消滅.
}

VANISH はペアができたときに消える処理なので、カードをめくる状態はそれ以外の値です。カードをめくるときの遷移図は以下のとおりです。

ポイントはカードを縮小する場合は “rot_rate” の値が “1.0 → 0.0” となり、カードを拡大する場合は “0.0 → 1.0” になるところです。

例えば以下の処理(裏から表になる)の場合は、裏にするために “rot_rate” の値が “1.0 → 0.0” となり、表にするときに “0.0 → 1.0” となります。

そのあたりの処理が少し長めの match 文の記述です。

## 更新.
func _physics_process(delta: float) -> void:
  ...
  _timer += delta
  var rot_rate = 1.0 # 回転の割合.
  var is_back_card = true # 裏向きかどうか.
  
  match _state:
    eState.BACK:
      # 裏向き.
      is_back_card = true
      rot_rate = 1.0
      # 選択カーソル更新.
      _update_blink_select(delta)
      
    eState.BACK_TO_FRONT1:
      # 裏 -> 表 (前半).
      is_back_card = true
      rot_rate = 1 - (_timer / TIME_ROTATE)
      if _timer >= TIME_ROTATE:
        # 後半へ続く.
        rot_rate = 0
        _timer = 0.0
        _state = eState.BACK_TO_FRONT2
    eState.BACK_TO_FRONT2:
      # 裏 -> 表 (後半).
      is_back_card = false
      rot_rate = _timer / TIME_ROTATE
      if _timer >= TIME_ROTATE:
        # 終了.
        is_back_card = false
        rot_rate = 1.0
        _state = eState.FRONT
      
    eState.FRONT:
      # 表向き.
      is_back_card = false
      rot_rate = 1.0
      
    eState.FRONT_TO_BACK1:
      # 表 -> 裏(前半).
      is_back_card = false
      rot_rate = 1 - (_timer / TIME_ROTATE)
      if _timer >= TIME_ROTATE:
        # 後半へ続く.
        rot_rate = 0
        _timer = 0.0
        _state = eState.FRONT_TO_BACK2
    eState.FRONT_TO_BACK2:
      # 表 -> 裏(後半).
      is_back_card = true
      rot_rate = _timer / TIME_ROTATE
      if _timer >= TIME_ROTATE:
        # 終了.
        is_back_card = true
        rot_rate = 1.0
        _state = eState.BACK
        
    eState.VANISH:
      # 消滅演出.
      var rate = Ease.cube_out(_timer / TIME_VANISH)
      var card_scale = 1 + rate
      _front.scale = Vector2.ONE * card_scale
      _front.modulate.a = 1 - rate
      if _timer >= TIME_VANISH:
        # 消える.
        queue_free()
      # 消滅はカードの更新を行わないのでここでreturnする.
      return
  
  # フラグに対応した回転処理を行う.
  _update_flip(is_back_card, rot_rate)

コードとしては長いですが、やっていることは先ほど説明したとおりです。

Main: メインシーン

最後にメインシーン (Main.tscn / .gd)です。

カードの生成

配置のもととなるデータは Array2 を使用して作成しています。Array2には設定した値をshuffle()でシャッフルしたり、foreach() でまとめて処理できます。

## 開始.
func _ready() -> void:
	## キャプションを消しておく.
	_label_caption.visible = false
	
	## スライダーの値を設定.
	_slider_kind.value = Common.cnt_card_kind
	
	## カード配置情報を作成.
	_create_card_array()
			
	## 配情報をシャッフルする.
	_array2.shuffle()
	## デバッグ出力.
	_array2.dump()
	
	## カードを生成.
	_array2.foreach(func(i, j, v):
		var pos = _grid_to_screen(i, j) # スクリーン座標.
		var idx = _grid_to_idx(i, j) # インデックス座標.
		var card = CARD_OBJ.instantiate() # カードインスタンス.
		_card_layer.add_child(card) # カードレイヤーに登録する.
		card.setup(pos, idx, v) # カードをセットアップ.
	)
	
	# 表のカード枚数計算用.
	_front_cnts.resize(Card.eId.size())
	_front_cnts.fill(0)

それと _ready() の最後の部分では “_front_cnts” という配列を作成しています。これは見つかったカードの枚数を記録するための変数です。

この配列でカードIDに対応する見つかった枚数を記録して、めくったカードがペアになっているかどうかを判定します。例えば以下のような状態であれば…

配列の要素番号 (=カードID)配列の値
0 (eId.NONE)0
1 (eId.NASU)1
2 (eId.TAKO)0
3 (eId.BOX)1
4 (eId.NYA)0
5 (eId.MILK)0
6 (eId.PUDDING)0
7 (eId.XBOX)0

というように見つかった値を記録します。

めくったカードの枚数を数える処理が _count_front_cards() となります。

## 表になっているカードの枚数を数える.
func _count_front_cards() -> void:
	# 0クリア.
	_front_cnts.fill(0)
	
	for card in _card_layer.get_children():
		var c = card as Card
		if c.is_front == false:
			continue # 対象カードは表のものだけ.
		# カウントアップ.
		_front_cnts[c.id] += 1

_card_layer からすべてのカードをチェックしていますが、めくったカードは変数 “_front_cards” に入れているのでこれを使えば Card.is_front の分岐が不要だったのかもしれません…。

_create_card_arrary(): カード配置情報を生成

説明が前後しますが、_create_card_array() は Array2 にカード配置情報を設定します。

## カード配置情報を作成.
func _create_card_array() -> void:
	var id = Card.eId.NASU
	var idx = 0
	
	# この数で難易度が決まる.
	var id_max = 1 + (Common.cnt_card_kind) # 出現するカードの数.
	
	for j in range(GRID_CNT_H):
		for i in range(GRID_CNT_W):
			_array2.set_v(i, j, id)
			idx += 1
			if idx%2 == 0:
				# 2枚ずつ作る.
				id = (id+1)%id_max
				if id == Card.eId.NONE:
					# 無効なカードになっていたら+1
					id += 1

2枚ずつペアになるようにして値を順に設定してきます。

消去チェック

消去チェックは “_check_erase()” で行っています。

## 消去チェック.
## @param is_erase 消去も同時に行うかどうか.
func _check_erase(is_erase:bool) -> bool:
	var ret = false # マッチしたカードがあるかどうか.
	
	var match_list = []
	for idx in range(_front_cnts.size()):
		if _front_cnts[idx] == 2:
			# 2枚表向きならそろっているとみなす.
			match_list.append(idx)
	
	for idx in range(_front_cnts.size()):
		if not idx in match_list:
			continue # マッチリストにいない.
		
		# マッチしたカードがある.
		ret = true
		
		# マッチしたカードを消す.
		for card in _front_cards:
			if card.id == idx:
				if is_erase:
					# 消去する.
					card.vanish()
	
	return ret

ペアになっているかどうかをチェックして、ペアになっていたら、Card.vanish() を呼び出してカードを消去しています。

改めてコードを見て思ったのですが、消去情報を_array2 には反映していないので、結局はインスタンスの状態を見て直接処理をしていました。このあたりデータと見た目の情報(インスタンス)に差異があるので、_array2に反映するように手直しした方が良いのかもしれません。

ゲームクリア判定

ゲームクリア判定は _update_MAIN()_card_layer に登録されているオブジェクトがなくなったかどうかで判定しています。

## 更新 > メイン.
func _update_MAIN(_delta:float) -> void:
	if _front_cards.size() >= 2:
		# カードを2枚めくったのでそろったかどうかの判定を行う
		_state = eState.WAIT1
		_timer = 0
		return
		
	if _card_layer.get_child_count() == 0:
		# 場のカードがなくなったらクリア.
		_state = eState.GAMECLEAR
		return	
	
	# めくり判定.
  ...