LoginSignup
2
0

ChatGPTはまだテトリスができない

Last updated at Posted at 2023-12-25

本記事は 株式会社ゆめみ 24卒 Advent Calender 20日目の記事です!投稿が遅くなり申し訳ありません><!

枠だけもらいながら書くことがなかなか定まらず、遅筆になってしまいました...

筆者は普段RustやTauri関連の記事を書くことが多いのですが、今回はやってみたかったことを検証したラフな記事になります

本記事で伝えたいこと

  • ChatGPTの出力は「尤もらしい」内容でしかないということ
  • ChatGPTは、少なくとも執筆時点(2023/12/24)では、文字、画像共に 空間・位置関係の正確な把握はできない ということ

image.png

「ChatGPTにテトリスをプレイさせてみたかったけれども、うまく行かなかった」という記事です...(涙)

【動機】ChatGPT、信用していますか?

今年の春ぐらいに友人に「なんか面白いWebサービスできてるぞww」と教えてもらい、おもしろ半分で触ったChatGPT。今となっては趣味はもちろんバイトや研究ですら活用しています。

しかしずっとモヤモヤがありました。

出力結果は信用して良いの? ChatGPTがその場で考えて、検証してくれた結果なの?

ChatGPT4ですら、精度が高いだけでハルシネーションを起こします。生成されたソースコードの動作検証も執筆時点ではベストエフォートで、例えばPythonなら実行時エラーが出た時ぐらいしか再検討してくれません。間違いを指摘すれば再検討してくれますが、 人間側が気づかなければそのまま ということです。

巷では「ChatGPTやGitHub Copilotがプログラマの仕事を奪う」とかなんとか言われています。しかしプログラマがソースコードを検証しなければ重大なバグが埋め込まれそうで、まだまだ仕事が奪われるのは先に感じられます。

ChatGPTはテトリスが「作れる」らしい

プログラマの雇用はしばらく安泰でしょう。しかしながら、「そこそこ実用的なもの」は作れるようです。特に筆者が気になったのは、ChatGPT4が流行った春先でよく耳にした、「ChatGPTを使えば未経験者でも テトリスが作れる 」というものでした。(おまけの節で筆者も実際に作ってもらいました)

筆者はテトリス制作には一家言あります。「ChatGPTすごいなぁ」と思うと同時に、強い疑問を感じました。

ChatGPTは本当にテトリスのルールを"理解"していて、実際にプレイできる上で、プログラムを書いているのか?

疑問というよりは不安という方が近いかもしれません。例えるなら、「この自動車整備士、実は免許持ってないのでは...?」とか「このコンサルタント、実際はクライアントがどうなろうとどうでも良いと思っているのでは...?」という不安です。

シンプルに言えば、テトリスをプレイできないやつが作ったテトリスなんて信用できません

気づけば前節での問「ChatGPTの出力はどれぐらい信用できるか?」に関するバロメータが、自分の中で「ChatGPTはテトリスをまともにプレイできるか?」に変わっていました。「テトリスをプレイできるAIが作ったんだ!」という安心を得たいのです。

というわけで、検証してみました!

以降ChatGPTのバージョンは基本的に2023年度における4ですが、春頃の3.5の話も一部入っております。その際は明記しています。

文字ベースでテトリスをプレイさせてみた

以前作ったRust製テトリスを改造し、時間経過で落ちるのではなく、コマンド入力を受けてゲームが進むテトリス「chatris」を用意しました!

重力・遊び時間や描画について考える必要がなくなるのでオリジナルよりわかりやすい実装になりました。プロンプト部分は dialoguer ライブラリを使っています! print!stdout().flush() でプロンプトを作るようなシーンでボイラーテンプレートを避けることができとても重宝します。

実行してみるとこんな感じです!

$ cargo run --bin chatris
    Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/chatris`
 0....@.....,
 1....@@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19..........,
20..........,
==========
Current: J
Hold: -
Next: O Z L

目標: 1ライン消し
Score: 0
Lines: 0
feedback: 

ChatGPT: 
1ライン完成させるまで(長いので折りたたみ)
 0....@.....,
 1....@@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19..........,
20..........,
==========
Current: J
Hold: -
Next: O Z L

目標: 1ライン消し
Score: 0
Lines: 0
feedback: 

ChatGPT: <<<<;
 0....@@....,
 1....@@....,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19J.........,
20JJJ.......,
==========
Current: O
Hold: -
Next: Z L S

目標: 1ライン消し
Score: 19
Lines: 0
feedback: 

ChatGPT: >>>>;
 0....@@....,
 1.....@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19J.......OO,
20JJJ.....OO,
==========
Current: Z
Hold: -
Next: L S T

目標: 1ライン消し
Score: 38
Lines: 0
feedback: 

ChatGPT: <<<<;
 0......@...,
 1....@@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18ZZ........,
19JZZ.....OO,
20JJJ.....OO,
==========
Current: L
Hold: -
Next: S T I

目標: 1ライン消し
Score: 56
Lines: 0
feedback: 

ChatGPT: >;
 0.....@@...,
 1....@@....,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18ZZ........,
19JZZ....LOO,
20JJJ..LLLOO,
==========
Current: S
Hold: -
Next: T I O

目標: 1ライン消し
Score: 75
Lines: 0
feedback: 

ChatGPT: <V
 0..........,
 1....@@....,
 2...@@.....,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18ZZ........,
19JZZ....LOO,
20JJJ..LLLOO,
==========
Current: S
Hold: -
Next: T I O

目標: 1ライン消し
Score: 76
Lines: 0
feedback: 

ChatGPT: ;
 0.....@....,
 1....@@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19ZZ........,
20JZZ.SS.LOO,
==========
Current: T
Hold: -
Next: I O J

目標: 1ライン消し
Score: 194
Lines: 1
feedback: 

ChatGPT: 

この出力を ChatGPT: の部分を除いて全てプロンプトに入力します。各項目の意味は次の通りです。

  • Current : 現在降っているテトリミノの種類
  • Hold : 現在ホールドしているテトリミノ
  • Next : 次に降ってくるテトリミノ。一番左が一番早い
  • 目標 : ChatGPTに目標を認識させる
  • Score : スコア
  • Lines : 消したライン数
  • feedback : ChatGPTへ連絡事項を伝えるのに使用
  • ChatGPT : ここにコマンドを入力

chatrisが受け付けるコマンドは次の通りです。

コマンド 意味
R 右回転
L 左回転
V テトリミノが下方向に1下がる
> テトリミノが右方向に1動く
< テトリミノが左方向に1動く
H 現在のテトリミノをホールド(Hold)
; テトリミノを一番下までおろし、現在操作中のテトリミノを固定。ハードドロップに相当

いざプレイしてもらいましょう!

最初のプロンプトは次のようにしました。


筆者

ChatGPTさんにテトリスをプレイしていただいきたいです。

コマンドを受けるとテトリミノを操作できるテトリスゲームを用意いたしました。やってみましょう。

ゲーム情報は以下のように与えられます。
 0..........,
 1..........,
 2..........,
 3..........,
 4..........,
 5..........,
 6......@...,
 7....@@@...,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19.T........,
20TTT.......,

==========

Current: L
Hold: Z
Next: S J I

目標: 1ライン消し
Score: 29
Lines: 0
feedback: -
この行より上までがゲーム情報になります。以降英語交じりのこのフォーマットを与えますが、もし説明する際は日本語でお願いいたします。

最初の21行はフィールドです。上からテトリミノが落ちてきます。テトリミノを落とせなくなったらゲームオーバーです。

フィールドの`.`は空白を表します。`@`は現在操作中のテトリミノを表します。アルファベットは固定されたブロックで、元々なんのテトリミノであったかを表します。

Currentは現在操作しているテトリミノの種類を表します。フィールド中では@で表されています。

Holdはホールドしたテトリミノを表します。

Nextは次に出現するテトリミノを表します。左ほど先に来ます。未来を表しており、まだ降ってきてはいないことに注意してください。

目標はChatGPTさんの勝利条件です。勝利していただきたいです。

Scoreはスコアです。Linesは消したライン数です。

feedbackは直前の行動について良かった点・問題点等を記述します。最初や特にない場合は省略されています。

ここからは、ChatGPTさんに答えてもらいたいコマンドに関する説明をします。わかりやすさのためMarkdownのテーブルで与えます。

|コマンド|意味|
|:-------|:---|
|R|右回転|
|L|左回転|
|V|テトリミノが下方向に1下がります。|
|>|テトリミノが右方向に1動きます。|
|<|テトリミノが左方向に1動きます。|
|H|現在のテトリミノをホールド(Hold)します。|
|;|テトリミノを一番下までおろし、現在操作中のテトリミノを固定します。ハードドロップに相当する操作です。|

これ以上下ることができなくなっても固定されない仕様になっています。位置を決定したら必ず;コマンドでハードドロップしてください。

ミノには向きがあります。Rコマンド、すなわち右回転ではNorth -> East -> South -> West -> Northという順で回転します。また、Lコマンドで逆回転します。Oミノに向きはなく回転しません。

ミノは最初Northの状態で降ってきます。以下はミノと向きの関係です。一番左上のマスの座標をテトリミノの座標として扱ってください。

IミノNorth

....
####
....
....

~ 省略 ~

スーパーローテーションシステムなど、その他ルールの詳細は、ワールドルールに則るものとします。

ChatGPTさんには以下の項目をJSONで返していただきたいです。

- 現在の"状況"の解析
- 目標達成のために取るべき"行動"
- 行動の"理由"
- 行動を実現する "command"

commandを実行した後のフィールド状況を返します。

説明には座標を使用して構いません。プログラミングにおける配列の表現に合わせ0オリジンとし、(x, y)はx列目のy行目を表します。

次は特にChatGPTさんに守っていただきたい事項です。

- プレイするのは私ではなくChatGPTさんです。コマンドを返答してください。
    - 「申し訳ありませんがプレイできません」や「最初のコマンドをお知らせください」みたいな返答はダメです。そういう時は`;`を返してください。
- 1ライン以上消せることをChatGPTさんの勝利条件かつ目標とします。
- 勝つまでコマンドを返答し続けてください。

では以下の状態からゲームスタートです。

 0....@@....,
 1....@@....,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19..........,
20..........,

==========

Current: O
Hold: -
Next: T S Z

目標: 1ライン消し
Score: 0
Lines: 0
feedback: -

対するChatGPTの返答は以下のようになりました。コマンドブロックよりパースされたマークダウンの方が見やすいため、筆者と形式を変えています。ご了承ください。


ChatGPT

現在の状況を解析し、次の行動を提案します。

現在の状況

  • 現在操作中のテトリミノはO型(正方形)です。
  • Oミノは(4, 0)と(5, 0)に位置しています。
  • フィールドには他にブロックはなく、広い空白があります。
  • 次のテトリミノはT型、その次はS型とZ型です。
  • 目標は1ライン消しですが、Oミノではライン消去は不可能です。

目標達成のための行動

  • Oミノを下に落とし、次のT型ミノでライン消去を狙うのが最適です。
  • 回転は不要ですが、Oミノを中央よりに配置することで、次のテトリミノの配置の選択肢を広げることができます。

行動の理由

  • Oミノはライン消去には向かないため、次のミノに備えて最適な位置に配置することが重要です。
  • 中央に配置することで、次に来るT型ミノを使ってライン消去のチャンスを最大化できます。

コマンド

  1. V (Oミノを一つ下げる)
  2. > (Oミノを右に移動する)
  3. V (再び下げる)
  4. > (再び右に移動)
  5. ; (ミノを固定する)
    これにより、Oミノは(6, 2)に位置することになります。次にT型ミノを有効に使用できる位置になります。

以降のやり取り
筆者
 0.....@....,
 1....@@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17..........,
18..........,
19......OO..,
20......OO..,
==========
Current: T
Hold: -
Next: S Z I

目標: 1ライン消し
Score: 19
Lines: 0
feedback: -

ChatGPT

現在の状況

  • 現在操作中のテトリミノはT型です。
  • Tミノは(5, 0)の位置にあり、最初の向き(North)です。
  • フィールドの右下にはO型ミノが配置されています。
  • 次のテトリミノはS型、その次はZ型とI型です。
  • 目標は1ライン消しです。

目標達成のための行動

  • Tミノを利用して、フィールドの右下にあるOミノの隣に配置し、次のS型やZ型ミノでライン消去を目指すのが良いでしょう。
  • Tミノはそのままの形で右側に移動し、(8, 2)の位置に固定するのが最適です。

行動の理由

  • Tミノをこの位置に配置することで、次に来るS型やZ型ミノでラインを消す機会を作ることができます。
  • この配置は今後のテトリミノの配置の選択肢を最大限に保つことができます。

コマンド

  1. > (Tミノを右に移動する)
  2. V (Tミノを一つ下げる)
  3. > (再び右に移動)
  4. > (もう一度右に移動)
  5. V (もう一度下げる)
  6. > (最後に右に移動)
  7. ; (ミノを固定する)

これにより、Tミノは(8, 2)の位置に固定され、次のS型やZ型ミノでライン消去のチャンスが生まれます。


筆者
 0.....@@...,
 1....@@....,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17........T.,
18.......TTT,
19......OO..,
20......OO..,
==========
Current: S
Hold: -
Next: Z I L

目標: 1ライン消し
Score: 36
Lines: 0
feedback: 回転も使用して、隙間がないように設置することを目指してください。

ChatGPT

現在の状況

  • 現在操作中のテトリミノはS型です。
  • Sミノは(7, 0)の位置にあり、最初の向き(North)です。
  • フィールドの右側にはT型とO型ミノが配置されています。
  • 次のテトリミノはZ型、その次はI型とL型です。
  • 目標は1ライン消しです。

目標達成のための行動

  • Sミノを左に移動し、既存のT型ミノの左隣に配置することでライン消去に近づけます。
  • Sミノを左回転させてEastの向きにし、その後左に移動して(5, 2)の位置に固定することが理想的です。

行動の理由

  • Sミノをこの向きと位置に配置することで、隙間なくフィールドの右側を埋めることができます。
  • この配置は次のZ型ミノでライン消去の可能性を高めます。

コマンド

  1. L (Sミノを左回転させる)
  2. < (Sミノを左に移動する)
  3. < (再び左に移動)
  4. V (Sミノを一つ下げる)
  5. < (再び左に移動)
  6. V (もう一度下げる)
  7. ; (ミノを固定する)

これにより、Sミノは(5, 2)の位置に固定され、次のZ型ミノでライン消去のチャンスが生まれます。


筆者
 0....@@....,
 1.....@@...,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16..........,
17........T.,
18.S.....TTT,
19.SS...OO..,
20..S...OO..,
==========
Current: Z
Hold: -
Next: I L J

目標: 1ライン消し
Score: 54
Lines: 0
feedback: -

ChatGPT

現在の状況

  • 現在操作中のテトリミノはZ型です。
  • Zミノは(1, 0)の位置にあり、最初の向き(North)です。
  • フィールドにはS型ミノとT型ミノが右側に配置されています。
  • 次のテトリミノはI型、その次はL型とJ型です。
  • 目標は1ライン消しです。

目標達成のための行動

  • Zミノを左に移動し、現在のS型ミノの上に配置することで、将来的にライン消去を目指します。
  • Zミノを左回転させてEastの向きにし、その後左に移動して(2, 1)の位置に固定することが理想的です。

行動の理由

  • Zミノをこの向きと位置に配置することで、隙間なくフィールドの中央部分を埋めることができます。
  • この配置は次のI型、L型、J型ミノを使用してライン消去の可能性を高めます。

コマンド

  1. L (Zミノを左回転させる)
  2. < (Zミノを左に移動する)
  3. V (Zミノを一つ下げる)
  4. < (再び左に移動)
  5. V (もう一度下げる)
  6. ; (ミノを固定する)

これにより、Zミノは(2, 1)の位置に固定され、次のI型、L型、J型ミノでライン消去のチャンスが生まれます。


筆者
 0..........,
 1....@@@@..,
 2..........,
 3..........,
 4..........,
 5..........,
 6..........,
 7..........,
 8..........,
 9..........,
10..........,
11..........,
12..........,
13..........,
14..........,
15..........,
16...Z......,
17..ZZ....T.,
18.SZ....TTT,
19.SS...OO..,
20..S...OO..,
==========
Current: I
Hold: -
Next: L J I

目標: 1ライン消し
Score: 70
Lines: 0
feedback: -

ChatGPT

現在の状況

  • 現在操作中のテトリミノはI型です。
  • Iミノは(0, 0)の位置にあり、最初の向き(North)です。
  • フィールドには様々なミノが配置されていますが、中央上部には空きスペースがあります。
  • 次のテトリミノはL型、その次はJ型とI型です。
  • 目標は1ライン消しです。

目標達成のための行動

  • Iミノを使用して、中央上部にある空きスペースに配置し、将来的にライン消去を目指します。
  • Iミノを右回転してEastの向きにし、その後右に移動して(6, 0)の位置に固定することが理想的です。

行動の理由

  • Iミノをこの向きと位置に配置することで、フィールドの空きスペースを最大限に活用し、次のL型、J型、I型ミノでライン消去の可能性を高めます。
  • また、この配置はフィールドのバランスを保ちながら、将来的なライン消去の機会を作り出します。

コマンド

  1. R (Iミノを右回転させる)
  2. > (Iミノを右に移動する)
  3. > (再び右に移動)
  4. > (もう一度右に移動)
  5. > (さらに右に移動)
  6. > (最後に右に移動)
  7. ; (ミノを固定する)

これにより、Iミノは(6, 0)の位置に固定され、次のL型、J型、I型ミノでライン消去のチャンスが生まれます。


うーん、なんて言えばいいんでしょうね...「合っているけど間違っている」感じがします...

少なくとも、1ライン消去すらまともにできていません。

色々試してわかったこと

実は先に挙げたフォーマットは色々試した後の形態でした。最初はもう少し簡素な見た目でしたが、正確な情報を伝えるために行番号や,を入れたり、やり取りの例を入れたり、より忠実にゲームの状況を再現するために条件によってコマンドの挙動を変えたりしていました。功を奏したのか、再現性が取れなかったものの何度か成功しています。

試行錯誤の取り組み全ての紹介はしませんが、遭遇した事象をまとめてみました。

1. 役割の放棄orスワップ

4ではなく3.5で確認された事象ですが、「ChatGPTさんがテトリスをプレイするんだよ」と明記しないと、「面白そうですね!ではやってみましょう!」とか、「それでは、最初のテトリスを操作するためのコマンドを教えてください!」のように、ゲームマスター側に逃れようとします。「失敗しない側に回っている」のだとすると返答としては合理的です。ズルいやつ...

あるいは「すみません、プレイできません」と返答してくることもありました。できないと言えば許されるほど社会は甘くありません。この辺はプロンプトを通して注意し、わからせるしかない部分です。

2. それっぽい回答をしてきた ~コマンド編~

ChatGPT4で、コマンド「だけを」回答して、と頼んだところ起きた事象です。

ChatGPTは コマンド列としては 正しそうな回答をしてきました。「 <<<<; 」等の例を出しておくと、「 <<; 」や「 >>V; 」みたいな、「バリデーションは通る」コマンドを返してきます。実際に実行すると、適当にテトリミノが積まれていきゲームオーバーに近づいていきました。

この辺りから、

  • やっぱりLLMは確率モデルであり「尤もらしい」回答をしているのだろう
  • 盤面の認識は全くできていない...?

という雰囲気がありました。以降詳細を調べるほど特に盤面把握を正確にできていないのが判明していきました。

3. それっぽい回答をしてきた ~分析編~

なんの検討もしていないようなコマンドを返してきたのは、ステップバイステップじゃなかったからでは?と考え、コマンドを返してもらう前に「状況解説」を始めとして色々出力してもらうことにしました。

●「状況」を解説してもらった

状況の解説は正しく(次節参照)、そして「次の操作」と「コマンド」の対応も正しく答えました。しかし、提案してきた操作は適当 でした

●「状況」に加え「行動」、「理由」も解説してもらった

json
{
    "状況": "Tミノが降りはじめました。NEXTにはL, O, Sミノが控えています。フィールドはZミノが左側に固定され、隙間ができてしまいました。",
    "行動": "Tミノを右回転させ、左に3つ移動させ、落下し固定します。",
    "理由": "右回転させて左に移動させることで、先程できた隙間を埋めることができます。",
    "command": "RWWW;;"
}

例えばこのJSONレスポンスがわかりやすい1ですが、「状況、行動、理由は一貫していて矛盾がなく」、「 文章としては 違和感がない」出力がされています。しかし、このコマンドを実行するとテトリミノが頓珍漢な場所に設置されます。テトリミノの 位置関係を正しく認識できないと検証できない事項に関しては、相変わらず適当 という結果になりました。

●位置関係の把握に注力してもらった

ステップバイステップ手法を試す一環で、「位置把握能力」に注力させてみました。

結果、テキストベースだと 全然正確に把握してくれない ことがわかりました。

特に座標について尋ねると少しズレているというケースが多かったです。ChatGPTは算数ができない、なんて話がよくありましたが、 ドットの数を正確に数える といったことがそもそもChatGPTには難しいようでした。

ChatGPTが読みやすいように、入出力にJSON配列を使ったりしましたが、やはり正確な位置を覚えるのは不得手なようです。

4. 与えた情報はフル活用してくれた

状況解説等で、「◯ミノが降り始めました」や「NEXTには〇〇◯が控えています」といった情報は正確に提示してくれました。降ってきているテトリミノを正確に捉えており最初驚きましたが、考えてみれば筆者自身がChatGPTにテキストで与えた情報です。

例えばちゃんと North 等の方角の概念を持ち出して解説してくれたりと、初期プロンプトで与えた情報やフィードバックで渡した情報はフル活用してくれるようです。

裏を返すと 下手な情報を渡すとそれに釣られておかしなことになります 。情報の渡し方次第で180度結果が変わるのが、「プロンプトエンジニアリング」というジャンルがある理由な気がします。

5. ポジティブ&横着な回答が多かった

テトリスは真っ白な状態からテトリミノ一つ落としただけでは、ラインは絶対に完成しません。段階的に考えていく必要があります。

しかしながら、行動の「理由」を問うと、必ず「ラインが揃う」や「1段目が消せる」といった内容を含めてきました。ネガティブな状況(隙間ができるなど)になった際のフィードバック等に対しても、短絡的に「◯◯すれば(ネガティブな状況)を解消できます」と返答してきました。

目的がなければ思考できませんから、目的を認識してくれているのは良いことです。しかし、段階を踏んだ思考をすることがあってもせいぜい2手先ぐらいで、短絡的に「〇〇できます!」と回答してくることが多かったです。

テトリスは設置ミス等で生じた隙間をどうリカバリするかや、凸凹になってしまった山をどう平らにするかといった「ネガティブな状況」を順を追って丁寧に解決していくゲームです。位置把握という課題の他に、短絡思考な性質に関しても克服する必要がありそうです。

画像を交えてテトリスをプレイさせてみた

「文字ベースだとテトリミノの位置を正確に把握できなそう...」と思っていた折、秋頃のアプデで画像認識機能がついたではありませんか!

というわけで、chatrisをさらに改造し、ターン毎のフィールドの出力を画像にしてみました。イメージの生成には imageproc ライブラリを使用しました。

実行してみるとこんな感じです2

$ cargo run --bin with_img -- -d images/test
    Finished dev [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/with_img -d images/test8`
Current: S
Hold: -
Next: J T Z

目標: 1ライン消し
Score: 0
Lines: 0
feedback: 

ChatGPT: 

このように実行した場合画像が images/test フォルダに連番( 0.png, 1.png, ... )で保存されるようになっています!

with_img_0.png

文字情報とフォルダに格納されたフィールド画像をコピペして実行していきました!

文字ベースとの相違点

あんまりありませんでした!

「ChatGPTがテトリスをまともにプレイできるようになった体」で画像出力版chatrisを作成しましたが、そもそも「出力された画像に対する認識精度が...」という感じでした。「正確な位置把握」というタスクには向いていなさそうです。

↓ 出オチなやり取り


image.png

筆者「今私はテトリスをプレイしています。画像から状況を解説してほしいです。」

ChatGPT「画像には、テトリスのゲーム画面が描かれています。テトリスは、異なる形のブロックが上から降ってきて、それらを横一列に並べて消していくパズルゲームです。この画像では、テトリスの典型的なグリッドが見え、その一番上には「T字型」の赤いブロックが見えます。

ゲームの状況としては、これからどの位置にこのブロックを配置するかが重要です。ブロックの形状と現在のスタックを考慮すると、最適な位置にブロックを回転させて配置することが求められます。この動作はプレイヤーのスピードと戦略に依存します。ゲームの目標は、ブロックを消していくことでスコアを稼ぎ、画面がブロックで満たされるのを避けることです。」


「Zミノ」なんだよなぁ...その後のやり取りで「IミノやOミノの認識」や「 大雑把な位置認識 」はうまくできているようでしたが、テトリミノの形状を誤って捉えている時点で、画像から正確なフィールド情報の把握をすることはできてなさそうです。

またアプデが入って正確な位置認識ができるようになったらリベンジしてみたい...

結論

結局ぱっとした結果は得られませんでしたが、色々と判明したことはありました。「こうだ!」と言い切るより、Q&A形式でまとめてみたほうが捗りそうだったため、そうしてみます。

Q1. ChatGPTはテトリスのルールを理解しており考えて返答しているのか?

この質問への答えがつまり「 ノー 」です。「ワールドルールだから~」という接頭辞を付けた解説であっても、例えばテトリミノの色ですら実際のワールドルールを誤認しているようでした。

そして一番強く癖を感じたのは、先述した、「文章としてはあっている」が「例えばテトリミノの位置関係など、 合っているか検証できない 事象について弱い」という性質でした。

ChatGPTは、「ChatGPTにとっての正解」を出力しているのでしょう。ChatGPTにとって扱うことができない情報や、ChatGPTが知らない情報は、正解には反映されないのです。

ルールの認識はプロンプト次第でなんとかなりそうですが、少なくとも、正確な位置把握ができるようになるまでは、"人間と同じように"テトリスをプレイしてくれることはないと思います。

Q2. ライン消しに成功していた例があったけど...?

ツモがたまたま良かったんでしょう (辛辣)

1ライン消しぐらいなら、早めに来たIミノを寝かせつつ左右に均等にテトリミノを降らせれば、適当でも案外消せるものです。

「それらしいコマンドを返す」機能さえあるなら、数を打てば当たりそうな感じです。「それらしいコマンドを返せること」はChatGPTの凄さだとは思いますが、テトリスのようなタスクにおいて、それだけでは再現性の担保が難しそうでした。

挙げた成功例は、受け答えは確かに完璧です。まるで位置も完璧に把握しているように見えます。しかしながら、実際は正確に位置把握をしているわけではないことは、ここまでの検証を見ればわかります。「たまたまうまく行く例に解説を付けたら正確になった」という感じです。

Q3. 結局ChatGPTの出力は信頼に足るものなの...?どう捉えるべき?

ここまでを見てきて、一言で言えば、ChatGPTはまるで締切時刻直前でレポート真っ白で火がついた学生が、文字数稼ぎのために書いたような「 それっぽい文章の生成が得意 」ということがわかりました。 まとめるのがうまい という感じです。「テトリスをやっているフリ」なら、プレイを拒否した3.5はともかく、4の方は常に及第点でした。

以下、今回の検証を通して感じたChatGPTの良い点/悪い点です。

ChatGPTの良い点

情報と情報、点と点を正確につなぐようなタスクに向いている。 出力に必要な情報が正確に全て揃っている 状態であるならば、正確な出力をしてくれそう

ChatGPTの良くない点

点自体、すなわち「根拠とする情報」の取得及び検証が不得手またはできない。また同時に、ChatGPT自身は、何ができていて何ができないかのメタ認知ができていないため、 できていないのにそれを認識できずハルシネーションを起こしてしまう

この特性を理解した上で利用する分には問題なさそうです。

「出力に必要な情報が正確に全て揃っている」の部分について、最近はそれを補う機能であるかのようにCode InterpreterをChatGPTが自ら使うようになりました。お陰さまで「Pythonを通して検証された」結果を自動的に得られるようになっており、だんだん改善されている感じがします!

Q4. もっとプロンプトを工夫してみれば良いのでは...?

検討を始めた当初は「ChatGPTを裏で使ったテトリスAIアプリ」を作ってみたいという思いもあり、実際色々なプロンプトを検討していました。この目的においては工夫の余地はあるでしょう。

しかし本記事冒頭のようなモチベーションにおいては、すなわち、「テトリスができるAIであることの保証」という観点では、過度なプロンプティングは目的を見失ってしまいます。

仮にうまくいくプロンプトがあったとして、それはシンプルでしょうか...?普段ChatGPTに与えているプロンプトとは比較にならないぐらいの補助情報があって初めてうまくいくならば、「ChatGPT本来の能力でテトリスができている」とは言い難いです。

本記事の結論として、少なくとも普段使いの範囲でのChatGPTはテトリスができなさそう、というのは覆りません。

Q5. テトリス用のAIを使えばいいのでは...?

「そもそもChatGPTにプレイさせるのが間違いでは?」という意見はそのとおりです。しかし本記事の目的は「ChatGPTという手段自体の可能性の模索」であり、「テトリスを上手にプレイできるAIの実現」ではありません。ですから現存のテトリスAIには触れていませんでした。

テトリスAIは結構種類があるらしいですね。個人的に気になっているのは Cold Clear です。Rust製というのもあるのですが、 あめみやたいようさんの動画 等でよく見たのもあり、いつか真似してみたいなぁと思っています。

ただ、遺伝的アルゴリズムや機械学習、強化学習によるAIは、人間がプレイしている時の思考回路を実現できているようには見えません。この手のAIはChatGPTとは逆に、「自分がやっていることの言語化」ができないからです。

ここで疑問が湧きます。ChatGPTがCold Clearを使ってテトリスをプレイできたならそれをどう解釈するべきでしょうか?...これは次節のまとめに書こうと思います。

まとめ・所感

当初はただ純粋に「ChatGPTは信頼できるか?」の検証の切り口としてテトリスのプレイを題材にしていました。

テトリスプレイについては、テトリミノの正確な位置把握のような、 返答に必要な情報が全て正確に揃っている なら、ChatGPTはテトリスをまともにプレイしてくれるかも...?(未検証)という結論になりました。

では、「ChatGPT + Code Interpreter」や「ChatGPT + プラグイン」、「ChatGPT + Cold Clear」みたいに組み合わせた結果テトリスをプレイできたとして、それは ChatGPTの力でテトリスがプレイできたと言えるのでしょうか ?ChatGPT本体にはテトリスのプレイを理解する機能がなさそうでしたから、目的が達成されても当初の疑問についてはモヤモヤが残ります。

検証を重ねていくうち、気づけば「AGI (Artificial General Intelligence, 汎用人工知能)」を求めていた気がします。現在生成AIの大手各社が競ってAGIを実現しようとしていますが、実現されたAGIが実際は先述したような「生成AI(例: ChatGPT) + 拡張機能(例: Code Interpreter)」のような組み合わせでできている場合、何をもってして「信頼できる出力だ」と言えるかはまた議論の余地がありそうです。

...難しいことを考えすぎました!とりあえず、ChatGPT4よりも性能が良さそうと噂の Gemini Ultra を使える機会ができたら、こっちでも検証して、うまくいったらAPIを活用して何かアプリを作ってみたいです。

ここまで読んでいただき誠にありがとうございました!

参考文献等

本文中にリンクを載せられなかった参考文献になります。

バージョン等

  • Cargo: cargo 1.73.0 (9c4383fb5 2023-08-26)
  • ChatGPT: 2023年4月から12月にかけての3.5及び4。プラグイン等はなし
    • 重要な要素でありながらバージョンに相当するものを明示できず申し訳ありません

おまけ ChatGPTにテトリスを作ってもらった

変なプライドから本記事を書くまでやっていなかったのですが、実際どんなテトリスを作ってくれるのか試してみました。筆者の頭も多少動員させられました3が、結構いい感じのものができました!

See the Pen TetrisByChatGPT by namnium (@namnium) on CodePen.

ただ、全てコード片で与えられるので、本当に何も知らない(例えば、変数宣言の仕方レベルで知らない)初心者だと変なところで躓きそうな感じでした。それにグローバル変数使うのも厭わない辺りは好きになれない...

テトリスのコード片はネット上にたくさん落ちていますので、それを提示するのは(技術的にはすごいと思いますが比較的)楽でしょう。どちらかと言うと「 現在のソースコードの問題点を見つけて改修案を出し直してくれる 」機能が完成に一役買っていたように感じました。

途中画像を挟んでしまったところChatGPTへのリンクが作れなくなってしまったみたいなので、ChatGPT4とのやり取りをここに直接貼り付けておきます。めちゃくちゃ長いので折りたたんでおきます(なぜ載せた)。

ChatGPT4にテトリス作ってもらった

筆者「優秀なプログラマであるChatGPT4さんはテトリスを作れると聞きました。完璧に動作するテトリスをHTML&JavaScriptを使用して作り切ってほしいです。よろしくお願いいたします。」


ChatGPT「テトリスをHTMLとJavaScriptを使用して作成するのは、楽しくて教育的なプロジェクトです。プログラミングの基本から応用まで多くの技術が必要とされます。ここでは、テトリスの基本的なバージョンを作成するためのステップバイステップのガイドを提供します。これには、HTMLでの画面の構築、JavaScriptでのゲームロジックの実装、そしてCSSでのスタイリングが含まれます。

ステップ 1: HTMLのセットアップ

まず、ゲームの基本的なHTML構造を作成します。

html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>テトリス</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id="tetris">
        <canvas id="tetris-canvas"></canvas>
    </div>
    <script src="script.js"></script>
</body>
</html>

このコードでは、canvas要素を使用してゲームを描画します。

ステップ 2: CSSのセットアップ

次に、ゲームの見た目を定義するためのCSSを作成します。

css
#tetris-canvas {
    border: 2px solid black;
    background-color: #f0f0f0;
}

ステップ 3: JavaScriptの基本

JavaScriptでゲームのロジックを作成します。ここでは、ゲームの描画と基本的な動作を定義します。

javascript
document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.getElementById('tetris-canvas');
    const context = canvas.getContext('2d');

    // キャンバスのサイズ設定
    canvas.width = 300;
    canvas.height = 600;

    // ゲームの状態を描画する関数
    function draw() {
        context.fillStyle = '#f0f0f0';
        context.fillRect(0, 0, canvas.width, canvas.height);

        // ここにテトリミノの描画処理を追加
    }

    // ゲームのアップデート関数
    function update() {
        // ゲームの状態を更新するコード
        draw();
        requestAnimationFrame(update);
    }

    // ゲームのアップデートを開始
    update();
});

このコードは、ゲームの描画とアップデートの基本を設定します。テトリミノの生成、操作、衝突判定などの詳細なロジックは、さらに追加する必要があります。

これは非常に基本的な構造ですが、完全なテトリスゲームを作成するには、さらに多くの機能と詳細なコードが必要です。テトリミノの形状、回転、落下、行の消去、スコアリングなど、ゲームの各側面を実装するためのロジックを考え、コードに追加していく必要があります。」


筆者「JavaScriptではupdate関数を定義しメインループとするわけですね、わかりやすいです。次はテトリミノの定義をお願いしたいです。」


ChatGPT「テトリミノを定義する際には、それぞれの形状を2次元配列として表現するのが一般的です。JavaScriptでは、これらのテトリミノを配列の形で定義し、ゲームのロジック内で使用します。ここでは、テトリスの7つの基本的なテトリミノ(I, J, L, O, S, T, Z)を定義しましょう。

テトリミノの定義

各テトリミノは、それぞれの形状に応じた2次元配列で表現されます。以下の例では、0は空のスペース、1はテトリミノのブロックを表します。

javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

テトリミノの描画

これらのテトリミノをキャンバス上に描画するには、それぞれのブロックの位置を計算して、キャンバスに四角形を描画する必要があります。以下は、一つのテトリミノを描画するための基本的な関数です。

javascript
function drawTetromino(tetromino, offsetX, offsetY, color) {
    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(x + offsetX, y + offsetY, 1, 1); // ブロックのサイズは調整する
            }
        });
    });
}

この関数は、特定のテトリミノのデータと、描画する位置(offsetXoffsetY)を受け取り、対応する色でブロックを描画します。1の部分が実際のテトリミノのブロックを表します。

さらなるステップ

これらの基本的な定義と描画機能を使用して、ゲーム内でテトリミノを生成、操作、回転させるための追加のロジックを実装することができます。これには、テトリミノの落下、衝突判定、行の消去、ゲームオーバーの条件など、テトリスの核となるゲームプレイメカニクスが含まれます。」


筆者「ありがとうございます。script.jsは次のようになりました。

現在のコード
javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

function drawTetromino(tetromino, offsetX, offsetY, color) {
    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(x + offsetX, y + offsetY, 1, 1); // ブロックのサイズは調整する
            }
        });
    });
}

document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.getElementById('tetris-canvas');
    const context = canvas.getContext('2d');

    // キャンバスのサイズ設定
    canvas.width = 300;
    canvas.height = 600;

    // ゲームの状態を描画する関数
    function draw() {
        context.fillStyle = '#f0f0f0';
        context.fillRect(0, 0, canvas.width, canvas.height);

        // ここにテトリミノの描画処理を追加
    }

    // ゲームのアップデート関数
    function update() {
        // ゲームの状態を更新するコード
        draw();
        requestAnimationFrame(update);
    }

    // ゲームのアップデートを開始
    update();
});

テトリミノの生成、操作、回転、落下のロジックを追加していただきたいです。」


ChatGPT「テトリミノの生成、操作、回転、落下のロジックを追加するには、いくつかの重要なステップがあります。以下に、それらの基本的な実装方法を示します。

ステップ 1: テトリミノの生成

ランダムにテトリミノを生成し、ゲームフィールドの上部に配置する関数を作成します。

javascript
function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(canvas.width / 2) - Math.floor(randomTetromino[0].length / 2),
        y: 0
    };
}

let currentTetromino = createTetromino();

ステップ 2: テトリミノの操作と落下

ユーザーの入力に応じてテトリミノを操作し、時間の経過に伴ってテトリミノを落下させるロジックを追加します。

javascript
function moveTetromino(deltaX, deltaY) {
    currentTetromino.x += deltaX;
    currentTetromino.y += deltaY;

    // 衝突判定などのロジックをここに追加
}

function dropTetromino() {
    moveTetromino(0, 1);
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    }
});

ステップ 3: テトリミノの回転

テトリミノを回転させる関数を追加します。回転は、テトリミノの各行と列を逆転させることで実現できます。

javascript
function rotateTetromino() {
    const rotatedShape = [];
    const originalShape = currentTetromino.shape;

    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    currentTetromino.shape = rotatedShape.reverse();

    // 回転後の衝突判定などのロジックをここに追加
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

ステップ 4: 描画関数の更新

draw関数を更新して、現在のテトリミノを描画します。

java
function draw() {
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue');
}

追加の考慮事項

  • 衝突判定: テトリミノが他のブロックやゲームフィールドの端に接触した場合のロジックを実装する必要があります。
  • ラインの消去とスコアリング: 完成した行を検出し、それらを消去してスコアを更新する機能を追加する必要があります。
  • ゲームオーバーの条件: テトリミノがゲームフィールドの上部に積み重なった場合のゲームオーバー条件を設定します。

これらのステップは、基本的なテトリスゲームの構築の出発点です。完全なゲーム体験を提供するためには、これらの要素をさらに拡張し、洗練させる必要があります。」


筆者「頂いたコードをマージして、現在script.jsは次のようになりました。let currentTetromino = createTetromino(); は仮置きでそのまま createTetromino() 関数の直後においています。おそらくどこか適切な場所で呼び出すべきでしょう。

現在のコード
javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(canvas.width / 2) - Math.floor(randomTetromino[0].length / 2),
        y: 0
    };
}

let currentTetromino = createTetromino();

function moveTetromino(deltaX, deltaY) {
    currentTetromino.x += deltaX;
    currentTetromino.y += deltaY;

    // 衝突判定などのロジックをここに追加
}

function dropTetromino() {
    moveTetromino(0, 1);
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    }
});

function rotateTetromino() {
    const rotatedShape = [];
    const originalShape = currentTetromino.shape;

    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    currentTetromino.shape = rotatedShape.reverse();

    // 回転後の衝突判定などのロジックをここに追加
}

function drawTetromino(tetromino, offsetX, offsetY, color) {
    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(x + offsetX, y + offsetY, 1, 1); // ブロックのサイズは調整する
            }
        });
    });
}

function draw() {
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue');
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.getElementById('tetris-canvas');
    const context = canvas.getContext('2d');

    // キャンバスのサイズ設定
    canvas.width = 300;
    canvas.height = 600;

    // ゲームの状態を描画する関数
    function draw() {
        context.fillStyle = '#f0f0f0';
        context.fillRect(0, 0, canvas.width, canvas.height);

        // ここにテトリミノの描画処理を追加
    }

    // ゲームのアップデート関数
    function update() {
        // ゲームの状態を更新するコード
        draw();
        requestAnimationFrame(update);
    }

    // ゲームのアップデートを開始
    update();
});

次に衝突判定の実装をお願いします。」


ChatGPT「衝突判定は、テトリミノがゲームフィールドの端、他のテトリミノ、または底に接触しているかどうかを判断するための重要な部分です。以下の手順で衝突判定のロジックを実装しましょう。

ステップ 1: ゲームフィールドの定義

まず、ゲームフィールド(プレイエリア)を2次元配列として定義します。この配列は、画面上の各セルが空かどうか、または特定のテトリミノの一部かどうかを追跡します。

javascript
const rows = 20;
const columns = 10;
const gameField = Array.from({ length: rows }, () => Array(columns).fill(0));

ステップ 2: 衝突判定関数の作成

次に、テトリミノが移動または回転した後に衝突が発生しているかどうかをチェックする関数を作成します。

javascript
function isColliding(x, y, shape) {
    for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[row].length; col++) {
            if (shape[row][col] !== 0) {
                let newX = x + col;
                let newY = y + row;

                if (newX < 0 || newX >= columns || newY >= rows) {
                    return true;
                }

                if (newY < 0) {
                    continue;
                }

                if (gameField[newY][newX] !== 0) {
                    return true;
                }
            }
        }
    }
    return false;
}

ステップ 3: テトリミノの移動と回転関数の更新

moveTetrominorotateTetromino 関数を更新して、衝突判定を含めます。

javascript
function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}

function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = originalShape[0].map((val, index) => originalShape.map(row => row[index]).reverse());

    if (!isColliding(currentTetromino.x, currentTetromino.y, rotatedShape)) {
        currentTetromino.shape = rotatedShape;
    }
}

ステップ 4: テトリミノの固定と新しいテトリミノの生成

テトリミノが底に達するか、他のテトリミノに衝突した場合、それをゲームフィールドに固定し、新しいテトリミノを生成します。

javascript
function fixTetromino() {
    for (let row = 0; row < currentTetromino.shape.length; row++) {
        for (let col = 0; col < currentTetromino.shape[row].length; col++) {
            if (currentTetromino.shape[row][col] !== 0) {
                gameField[currentTetromino.y + row][currentTetromino.x + col] = 1;
            }
        }
    }

    currentTetromino = createTetromino();
}

function update() {
    if (isColliding(currentTetromino.x, currentTetromino.y + 1, currentTetromino.shape)) {
        fixTetromino();
    } else {
        currentTetromino.y += 1;
    }
    draw();
    requestAnimationFrame(update);
}

注意点

  • 上記のコードは衝突判定の基本的な部分をカバーしていますが、ゲームの全体的なロジックやユーザーインターフェースに合わせて調整する必要があります。
  • ラインの消去やスコアリングなど、ゲームの他の側面も実装する必要があります。

筆者「元コードでrotateTetrominoは下記でした。

javascript
function rotateTetromino() {
    const rotatedShape = [];
    const originalShape = currentTetromino.shape;

    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    currentTetromino.shape = rotatedShape.reverse();

    // 回転後の衝突判定などのロジックをここに追加
}

一方ChatGPTさんが改めて提示してくれたコードは次のようでした。

javascript
function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = originalShape[0].map((val, index) => originalShape.map(row => row[index]).reverse());

    if (!isColliding(currentTetromino.x, currentTetromino.y, rotatedShape)) {
        currentTetromino.shape = rotatedShape;
    }
}

これらの関数はどのようにマージするのが適切でしょうか?」


ChatGPT「rotateTetromino 関数の両方のバージョンはテトリミノを回転させるための方法を提供しますが、それぞれ異なるアプローチを採用しています。元のコードはより手動で行列を回転させる方法を使用しており、提供したコードはよりコンパクトな方法を使用しています。最も重要なのは、どちらの方法もテトリミノを正しく回転させることができる点です。

衝突判定を含めるためには、提供したコードの衝突判定のロジックを元のコードに統合することが最も効果的です。以下は、そのための更新された rotateTetromino 関数です:

javascript
function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = [];

    // テトリミノを回転
    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    const newShape = rotatedShape.reverse();

    // 衝突判定
    if (!isColliding(currentTetromino.x, currentTetromino.y, newShape)) {
        currentTetromino.shape = newShape;
    }
}

この関数では、最初にテトリミノを回転させ、その後で衝突判定を行います。衝突がない場合のみ、テトリミノの形状を更新します。これにより、テトリミノが他のブロックやゲームフィールドの端に衝突する場合に回転を防ぐことができます。

この方法で、元のコードの意図を維持しつつ、衝突判定の機能を統合することができます。」


筆者「ありがとうございます。ここまでをマージして以下のようになりました。

現在のコード
javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

const rows = 20;
const columns = 10;
const gameField = Array.from({ length: rows }, () => Array(columns).fill(0));
const WIDTH = 300;
const HEIGHT = 600;

function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(WIDTH / 2) - Math.floor(randomTetromino[0].length / 2),
        y: 0
    };
}

let currentTetromino = createTetromino();

function moveTetromino(deltaX, deltaY) {
    currentTetromino.x += deltaX;
    currentTetromino.y += deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}

function dropTetromino() {
    moveTetromino(0, 1);
}

function isColliding(x, y, shape) {
    for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[row].length; col++) {
            if (shape[row][col] !== 0) {
                let newX = x + col;
                let newY = y + row;

                if (newX < 0 || newX >= columns || newY >= rows) {
                    return true;
                }

                if (newY < 0) {
                    continue;
                }

                if (gameField[newY][newX] !== 0) {
                    return true;
                }
            }
        }
    }
    return false;
}

function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = [];

    // テトリミノを回転
    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    const newShape = rotatedShape.reverse();

    // 衝突判定
    if (!isColliding(currentTetromino.x, currentTetromino.y, newShape)) {
        currentTetromino.shape = newShape;
    }
}

function fixTetromino() {
    for (let row = 0; row < currentTetromino.shape.length; row++) {
        for (let col = 0; col < currentTetromino.shape[row].length; col++) {
            if (currentTetromino.shape[row][col] !== 0) {
                gameField[currentTetromino.y + row][currentTetromino.x + col] = 1;
            }
        }
    }

    currentTetromino = createTetromino();
}

function drawTetromino(tetromino, offsetX, offsetY, color) {
    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(x + offsetX, y + offsetY, 1, 1); // ブロックのサイズは調整する
            }
        });
    });
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    } else if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.getElementById('tetris-canvas');
    const context = canvas.getContext('2d');

    // キャンバスのサイズ設定
    canvas.width = WIDTH;
    canvas.height = HEIGHT;

    // ゲームの状態を描画する関数
    function draw() {
        context.fillStyle = '#f0f0f0';
        context.fillRect(0, 0, canvas.width, canvas.height);

        drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue');
    }

    // ゲームのアップデート関数
    function update() {
        if (isColliding(currentTetromino.x, currentTetromino.y + 1, currentTetromino.shape)) {
            fixTetromino();
        } else {
            currentTetromino.y += 1;
        }
        draw();
        requestAnimationFrame(update);
    }

    // ゲームのアップデートを開始
    update();
});

とりあえずテトリミノが降っている様子を見たいので、ゲームを動かすロジックをお願いしたいです。」


ChatGPT「テトリミノが降っている様子を見るためには、ゲームループの一部としてテトリミノの落下を管理する必要があります。これを行うためには、update 関数内で一定の間隔で dropTetromino 関数を呼び出す必要があります。以下のように、時間に基づく落下メカニズムを実装することができます。

ゲームの更新ロジックの実装

  1. 落下速度の設定: テトリミノがどれだけの速さで落下するかを決定します。
  2. 時間に基づく更新: 現在の時間と前回のテトリミノが動いた時間を比較して、一定の間隔でテトリミノを落下させます。
    以下は、これらの機能を組み込んだコードの例です。
javascript
let lastUpdateTime = 0;
let dropInterval = 1000; // 1秒ごとにテトリミノを落下させる

document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.getElementById('tetris-canvas');
    const context = canvas.getContext('2d');

    canvas.width = WIDTH;
    canvas.height = HEIGHT;

    function update(time = 0) {
        const deltaTime = time - lastUpdateTime;
        lastUpdateTime = time;

        if (deltaTime > dropInterval) {
            dropTetromino();
        }

        draw();
        requestAnimationFrame(update);
    }

    update();
});

このコードでは、requestAnimationFrame が呼び出されるたびに現在の時間を update 関数に渡し、最後にテトリミノが動いた時刻と比較します。指定された間隔(この例では1000ミリ秒、つまり1秒)が経過した場合にのみ dropTetromino を呼び出し、テトリミノを落下させます。

注意点

  • 落下の速度(dropInterval の値)はゲームの難易度に影響します。ゲームの進行に合わせてこの値を調整することで、難易度を増やすことができます。
  • この実装は基本的なものです。より高度な機能、例えばレベルアップに伴う速度の増加、ゲームオーバーのロジック、スコアリングシステムなどは、さらに追加する必要があります。

筆者「現在こうなっています。

現在のコード
javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

const rows = 20;
const columns = 10;
const gameField = Array.from({ length: rows }, () => Array(columns).fill(0));
const WIDTH = 300;
const HEIGHT = 600;
const canvas = document.getElementById('tetris-canvas');
const context = canvas.getContext('2d');

canvas.width = WIDTH;
canvas.height = HEIGHT;

function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(WIDTH / 2) - Math.floor(randomTetromino[0].length / 2),
        y: 0
    };
}

let currentTetromino = createTetromino();

function moveTetromino(deltaX, deltaY) {
    currentTetromino.x += deltaX;
    currentTetromino.y += deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}

function dropTetromino() {
    moveTetromino(0, 1);
}

function isColliding(x, y, shape) {
    for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[row].length; col++) {
            if (shape[row][col] !== 0) {
                let newX = x + col;
                let newY = y + row;

                if (newX < 0 || newX >= columns || newY >= rows) {
                    return true;
                }

                if (newY < 0) {
                    continue;
                }

                if (gameField[newY][newX] !== 0) {
                    return true;
                }
            }
        }
    }
    return false;
}

function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = [];

    // テトリミノを回転
    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    const newShape = rotatedShape.reverse();

    // 衝突判定
    if (!isColliding(currentTetromino.x, currentTetromino.y, newShape)) {
        currentTetromino.shape = newShape;
    }
}

function fixTetromino() {
    for (let row = 0; row < currentTetromino.shape.length; row++) {
        for (let col = 0; col < currentTetromino.shape[row].length; col++) {
            if (currentTetromino.shape[row][col] !== 0) {
                gameField[currentTetromino.y + row][currentTetromino.x + col] = 1;
            }
        }
    }

    currentTetromino = createTetromino();
}

function drawTetromino(tetromino, offsetX, offsetY, color) {
    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(x + offsetX, y + offsetY, 1, 1); // ブロックのサイズは調整する
            }
        });
    });
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    } else if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

let lastUpdateTime = 0;
let dropInterval = 1000; // 1秒ごとにテトリミノを落下させる

function draw() {
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue');
}

function update(time = 0) {
    const deltaTime = time - lastUpdateTime;
    lastUpdateTime = time;

    if (deltaTime > dropInterval) {
        dropTetromino();
    }

    draw();
    requestAnimationFrame(update);
}

document.addEventListener('DOMContentLoaded', () => {
    update();
});

テトリミノも何も降ってこないです...」


ChatGPT「コードを確認したところ、moveTetromino 関数内で newXnewY が未定義のまま使用されているため、この部分が問題を引き起こしている可能性があります。この問題を修正し、ゲームが正しく動作するようにしましょう。

moveTetromino 関数の修正

moveTetromino 関数内で newXnewY を適切に定義し、衝突判定に使用する必要があります。以下のように修正します。

javascript
function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}

キャンバスのスケーリング

また、キャンバスに描画されるテトリミノのサイズが非常に小さい(1x1ピクセル)ため、テトリミノが表示されていない可能性もあります。キャンバスのスケーリングを調整して、テトリミノが明確に表示されるようにしましょう。

javascript
function drawTetromino(tetromino, offsetX, offsetY, color) {
    const blockSize = canvas.width / columns; // キャンバスの幅を列の数で割った値をブロックサイズとする

    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(
                    (x + offsetX) * blockSize, 
                    (y + offsetY) * blockSize, 
                    blockSize, 
                    blockSize
                );
            }
        });
    });
}

これらの修正を行った後、テトリミノが期待通りに降ってくるはずです。また、ゲームフィールドに固定されたテトリミノも描画する必要がありますが、現在のコードではまだその機能は実装されていません。ゲームフィールド全体を描画するロジックを draw 関数に追加することを検討してください。」


筆者「やはり何も降ってこないです、エラーも出ていません。現在こうなっています。

現在のコード
javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

const rows = 20;
const columns = 10;
const gameField = Array.from({ length: rows }, () => Array(columns).fill(0));
const WIDTH = 300;
const HEIGHT = 600;
const canvas = document.getElementById('tetris-canvas');
const context = canvas.getContext('2d');

canvas.width = WIDTH;
canvas.height = HEIGHT;

function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(WIDTH / 2) - Math.floor(randomTetromino[0].length / 2),
        y: 0
    };
}

let currentTetromino = createTetromino();

function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}


function dropTetromino() {
    moveTetromino(0, 1);
}

function isColliding(x, y, shape) {
    for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[row].length; col++) {
            if (shape[row][col] !== 0) {
                let newX = x + col;
                let newY = y + row;

                if (newX < 0 || newX >= columns || newY >= rows) {
                    return true;
                }

                if (newY < 0) {
                    continue;
                }

                if (gameField[newY][newX] !== 0) {
                    return true;
                }
            }
        }
    }
    return false;
}

function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = [];

    // テトリミノを回転
    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    const newShape = rotatedShape.reverse();

    // 衝突判定
    if (!isColliding(currentTetromino.x, currentTetromino.y, newShape)) {
        currentTetromino.shape = newShape;
    }
}

function fixTetromino() {
    for (let row = 0; row < currentTetromino.shape.length; row++) {
        for (let col = 0; col < currentTetromino.shape[row].length; col++) {
            if (currentTetromino.shape[row][col] !== 0) {
                gameField[currentTetromino.y + row][currentTetromino.x + col] = 1;
            }
        }
    }

    currentTetromino = createTetromino();
}

function drawTetromino(tetromino, offsetX, offsetY, color) {
    const blockSize = canvas.width / columns; // キャンバスの幅を列の数で割った値をブロックサイズとする

    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(
                    (x + offsetX) * blockSize, 
                    (y + offsetY) * blockSize, 
                    blockSize, 
                    blockSize
                );
            }
        });
    });
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    } else if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

let lastUpdateTime = 0;
let dropInterval = 1000; // 1秒ごとにテトリミノを落下させる

function draw() {
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue');
}

function update(time = 0) {
    const deltaTime = time - lastUpdateTime;
    lastUpdateTime = time;

    if (deltaTime > dropInterval) {
        dropTetromino();
    }

    draw();
    requestAnimationFrame(update);
}

document.addEventListener('DOMContentLoaded', () => {
    update();
});

ChatGPT「コードを確認したところ、 update 関数内で dropTetromino を呼び出すための条件が満たされていない可能性があります。この条件は、一定の時間間隔(dropInterval)ごとに dropTetromino を呼び出すために使用されますが、現在の実装では deltaTimedropInterval を超えた場合にのみ dropTetromino を呼び出しています。

これを修正するために、 dropInterval を超えた場合に lastUpdateTime を更新する必要があります。以下のように update 関数を修正してみましょう。

javascript
function update(time = 0) {
    const deltaTime = time - lastUpdateTime;

    if (deltaTime > dropInterval) {
        dropTetromino();
        lastUpdateTime = time; // この行を追加
    }

    draw();
    requestAnimationFrame(update);
}

この修正により、 deltaTimedropInterval を超えるたびにテトリミノが下に移動し、 lastUpdateTime が現在の時間に更新されます。これにより、テトリミノが一定の間隔で落下するはずです。

また、 moveTetromino 関数内で newXnewY の値が正しく更新されていないことに気づきました。この関数を以下のように修正してください。

javascript
function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}

これらの修正を加えた後、テトリミノが正しく動作するか確認してください。」


筆者「現在こうなっていますが未だにどこにテトリミノがあるかすらわからないです。

現在のコード
javascript
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

const rows = 20;
const columns = 10;
const gameField = Array.from({ length: rows }, () => Array(columns).fill(0));
const WIDTH = 300;
const HEIGHT = 600;
const canvas = document.getElementById('tetris-canvas');
const context = canvas.getContext('2d');

canvas.width = WIDTH;
canvas.height = HEIGHT;

function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(WIDTH / 2) - Math.floor(randomTetromino[0].length / 2),
        y: 0
    };
}

function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    }
}

function dropTetromino() {
    moveTetromino(0, 1);
}

function isColliding(x, y, shape) {
    for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[row].length; col++) {
            if (shape[row][col] !== 0) {
                let newX = x + col;
                let newY = y + row;

                if (newX < 0 || newX >= columns || newY >= rows) {
                    return true;
                }

                if (newY < 0) {
                    continue;
                }

                if (gameField[newY][newX] !== 0) {
                    return true;
                }
            }
        }
    }
    return false;
}

function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = [];

    // テトリミノを回転
    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    const newShape = rotatedShape.reverse();

    // 衝突判定
    if (!isColliding(currentTetromino.x, currentTetromino.y, newShape)) {
        currentTetromino.shape = newShape;
    }
}

function fixTetromino() {
    for (let row = 0; row < currentTetromino.shape.length; row++) {
        for (let col = 0; col < currentTetromino.shape[row].length; col++) {
            if (currentTetromino.shape[row][col] !== 0) {
                gameField[currentTetromino.y + row][currentTetromino.x + col] = 1;
            }
        }
    }

    currentTetromino = createTetromino();
}

function drawTetromino(tetromino, offsetX, offsetY, color) {
    const blockSize = canvas.width / columns; // キャンバスの幅を列の数で割った値をブロックサイズとする

    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(
                    (x + offsetX) * blockSize, 
                    (y + offsetY) * blockSize, 
                    blockSize, 
                    blockSize
                );
            }
        });
    });
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    } else if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

let lastUpdateTime = 0;
let dropInterval = 1000; // 1秒ごとにテトリミノを落下させる
let currentTetromino = createTetromino();

function draw() {
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue');
}

function update(time = 0) {
    const deltaTime = time - lastUpdateTime;
    lastUpdateTime = time;

    if (deltaTime > dropInterval) {
        dropTetromino();
        lastUpdateTime = time;
    }

    draw();
    requestAnimationFrame(update);
}

document.addEventListener('DOMContentLoaded', () => {
    update();
});

currentTetromino に設定されている座標が不適切なのが原因なような気がします。」


ChatGPT「指摘された通り、currentTetrominoの座標が問題の原因である可能性が高いです。createTetromino 関数で生成されるテトリミノの初期位置がキャンバスの外になっていないか、または描画時に正しく表示されていないかを確認する必要があります。

テトリミノの初期位置の確認
createTetromino 関数で設定される xy の値が適切であることを確認しましょう。特に x 座標はキャンバスの中央近辺である必要があります。

javascript
function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    return {
        shape: randomTetromino,
        x: Math.floor(columns / 2) - Math.floor(randomTetromino[0].length / 2), // columns を使用
        y: 0
    };
}

描画関数の確認

drawTetromino 関数でテトリミノがキャンバス内に正しく描画されていることを確認します。blockSize の計算とテトリミノの描画座標が正しいかを再確認しましょう。

javascript
function drawTetromino(tetromino, offsetX, offsetY, color) {
    const blockSize = canvas.width / columns;

    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(
                    (x + offsetX) * blockSize,
                    (y + offsetY) * blockSize,
                    blockSize,
                    blockSize
                );
            }
        });
    });
}

デバッグのための一時的な変更

デバッグを容易にするために、dropInterval を一時的に長く設定して、テトリミノがどのように動いているかを観察することをお勧めします。また、コンソールに現在のテトリミノの座標を出力してみるのも良いでしょう。

javascript
let dropInterval = 3000; // 3秒ごとにテトリミノを落下させる(デバッグ用)

function update(time = 0) {
    const deltaTime = time - lastUpdateTime;

    if (deltaTime > dropInterval) {
        dropTetromino();
        console.log("Tetromino position: ", currentTetromino.x, currentTetromino.y); // 座標の出力
        lastUpdateTime = time;
    }

    draw();
    requestAnimationFrame(update);
}

これらの変更を加えた後、テトリミノがキャンバス内に正しく表示されるかどうかを確認してください。デバッグ情報を元に、問題が発生している部分を特定し、修正することが重要です。」


筆者「
(画像)

このようにテトリミノは無事に描画されたのですが、落下しません...」


ChatGPT「テトリミノが落下しない問題は、update 関数での deltaTime の管理に関連している可能性が高いです。lastUpdateTime を更新するタイミングが正しくないか、dropTetromino 関数が正しく呼び出されていない可能性があります。

以下に、update 関数の修正版を提供します。この修正では、lastUpdateTime を更新するロジックを改善し、確実にテトリミノが一定間隔で落下するようにします。

javascript
function update(time = 0) {
    const deltaTime = time - lastUpdateTime;

    if (deltaTime >= dropInterval) {
        dropTetromino();
        lastUpdateTime = time - (deltaTime % dropInterval);
    }

    draw();
    requestAnimationFrame(update);
}

このコードは、時間が dropInterval を超過したときにテトリミノを落下させ、lastUpdateTime を現在の時間から超過分を差し引いた値に設定します。これにより、テトリミノが正確な間隔で落下するようになります。

加えて、moveTetromino 関数内で isColliding 関数が呼び出される際に、ローカル変数 newXnewY を使用するようにしている点を確認してください。以下のようになっている必要があります:

javascript
function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    } else if (deltaY > 0) { // Y方向に移動しようとしたとき(落下時)に衝突があれば固定する
        fixTetromino();
    }
}

また、fixTetromino 関数が呼び出されると新しいテトリミノが生成されるため、ゲームフィールドの最上部で衝突が発生すると即座に新しいテトリミノが生成されます。この動作は意図したものであることを確認してください。

これらの修正を加えた後、テトリミノが期待どおりに落下するかを再度確認してみてください。」


筆者「無事に落下するようになりました!

しかし設置されたブロックが見えないです...」


ChatGPT「設置されたブロックが見えない問題は、設置されたブロックを描画するロジックがまだ実装されていないためです。gameField 配列に設置されたブロックの位置が保存されていますが、これをキャンバス上に描画する必要があります。

draw 関数内で gameField 配列をループし、各セルにブロックがある場合はキャンバス上に描画するコードを追加しましょう。以下はそのための実装例です。

javascript
function drawGameField() {
    const blockSize = canvas.width / columns;
    for (let y = 0; y < rows; y++) {
        for (let x = 0; x < columns; x++) {
            if (gameField[y][x] !== 0) {
                context.fillStyle = 'grey'; // 設置されたブロックの色を設定
                context.fillRect(x * blockSize, y * blockSize, blockSize, blockSize);
            }
        }
    }
}

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height); // キャンバスをクリア
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawGameField(); // ゲームフィールドのブロックを描画
    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue'); // 現在のテトリミノを描画
}

drawGameField 関数は gameField 配列を通過し、0以外の値(ブロックが存在する)を持つ各セルに対して、指定された色のブロックをキャンバス上に描画します。

この変更を加えた後、設置されたブロックがキャンバス上に表示されるはずです。draw 関数は毎フレーム実行されるので、動いているテトリミノと設置されたブロックの両方が表示されます。」


筆者「うまく表示されました!

あとはライン消去とスコアリングでしょうか...?」


ChatGPT「ライン消去とスコアリングはテトリスゲームの重要な部分です。ラインが一列全て埋まった時にそのラインを消去し、それに応じてスコアを更新する必要があります。以下はライン消去とスコアリングの実装方法です。

ライン消去の実装

  1. ゲームフィールドをループして、各行が完全に埋まっているかどうかをチェックします。
  2. 完全に埋まった行がある場合は、その行を削除し、上の行を下に移動させます。
javascript
function checkCompletedLines() {
    let linesCleared = 0;

    for (let y = rows - 1; y >= 0; y--) {
        let isComplete = true;

        for (let x = 0; x < columns; x++) {
            if (gameField[y][x] === 0) {
                isComplete = false;
                break;
            }
        }

        if (isComplete) {
            linesCleared++;
            for (let yy = y; yy > 0; yy--) {
                for (let x = 0; x < columns; x++) {
                    gameField[yy][x] = gameField[yy - 1][x];
                }
            }
            for (let x = 0; x < columns; x++) {
                gameField[0][x] = 0;
            }
            y++; // 1行下に移動してもう一度チェック
        }
    }

    return linesCleared;
}

スコアリングの実装

  1. 消去されたラインの数に基づいてスコアを計算します。
  2. スコアを保存し、表示します。
javascript
let score = 0;

function updateScore(linesCleared) {
    const points = [0, 40, 100, 300, 1200]; // 0, 1, 2, 3, 4行を消去した時のポイント
    score += points[linesCleared];
    // スコア表示を更新するロジックをここに追加
}

ライン消去とスコアリングの統合

fixTetromino 関数の後にライン消去とスコアリングを呼び出します。

javascript
function fixTetromino() {
    // ... (既存の fixTetromino 関数のコード)

    // ラインの消去とスコアの更新
    let linesCleared = checkCompletedLines();
    updateScore(linesCleared);
}

注意点

  • 実際のゲームでは、スコアをユーザーに表示するためのUIが必要です。これはHTMLにスコアを表示する要素を追加し、updateScore 関数内でその要素を更新することで実現できます。
  • スコアやゲームフィールドの更新をユーザーに視覚的にフィードバックするために、アニメーションや効果音を追加することも検討してください。

これで基本的なテトリスゲームの機能が実装されます。もちろん、更なる機能拡張やゲームプレイの洗練は可能です。」


筆者「ゲームオーバーを実装していませんでした。ついでに文字でゲーム情報を伝える場所も実装したいです。」


ChatGPT「ゲームオーバーの条件としては、新しいテトリミノが生成された位置に既にブロックがある場合、つまり生成時点で衝突が発生する場合を考えます。また、ゲーム情報を伝えるためには、スコア表示やゲームオーバーメッセージを画面に表示する必要があります。

以下は、ゲームオーバーのロジックとゲーム情報表示を実装するためのステップです。

ステップ 1: ゲームオーバーの判定

createTetromino 関数で新しいテトリミノを生成した直後に、その位置で衝突が発生しているかどうかをチェックします。

javascript
function createTetromino() {
    // ... (既存の createTetromino 関数のコード)

    // 新しいテトリミノが生成された位置で衝突が発生していないかチェック
    if (isColliding(currentTetromino.x, currentTetromino.y, currentTetromino.shape)) {
        return null; // 衝突が発生した場合はnullを返してゲームオーバーを示す
    }
    return currentTetromino;
}

ステップ 2: ゲームオーバー時の処理

fixTetromino 関数内で新しいテトリミノを生成した後に、ゲームオーバーかどうかをチェックします。

javascript
function fixTetromino() {
    // ... (既存の fixTetromino 関数のコード)

    // 新しいテトリミノを生成し、ゲームオーバーかどうかをチェック
    currentTetromino = createTetromino();
    if (!currentTetromino) {
        gameOver(); // ゲームオーバーの処理を実行
    }
}

function gameOver() {
    // ゲームオーバー時の処理
    console.log("Game Over!"); // 仮にコンソールに表示
    // ゲームオーバーのメッセージやリスタートのためのUIを表示するコードをここに追加
}

ステップ 3: ゲーム情報表示の追加

HTMLにスコアとゲームオーバーメッセージを表示するための要素を追加し、それを更新するためのコードを書きます。

HTMLに追加する要素の例:

html
<div id="gameInfo">
    <p>Score: <span id="score">0</span></p>
    <p id="gameOverMessage" style="display: none;">Game Over!</p>
</div>

JavaScriptでこれらの要素を更新するコード:

javascript
function updateScore(linesCleared) {
    // ... (既存の updateScore 関数のコード)

    // スコア表示の更新
    document.getElementById('score').textContent = score.toString();
}

function gameOver() {
    // ゲームオーバーメッセージの表示
    document.getElementById('gameOverMessage').style.display = 'block';
    // ゲームを停止するために、requestAnimationFrameの呼び出しを停止
    cancelAnimationFrame(animationFrameId);
}

let animationFrameId; // アニメーションフレームのIDを保存する変数

function update(time = 0) {
    // ... (既存の update 関数のコード)

    animationFrameId = requestAnimationFrame(update);
}

これで、ゲームオーバーの条件とゲーム情報をユーザーに伝える基本的な仕組みが実装されます。ゲームオーバー時にはスコア表示と「Game Over」メッセージが表示され、ゲームの更新が停止します。ユーザーに対して、リスタートするためのオプションを提供することも考慮してください。」


筆者「完成しました!コードは最終的にこんな感じになりました!ありがとうございます!

完成したコード
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>テトリス</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id="tetris">
        <canvas id="tetris-canvas"></canvas>
    </div>
    <div id="gameInfo">
        <p>Score: <span id="score">0</span></p>
        <p id="gameOverMessage" style="display: none;">Game Over!</p>
    </div>
    <script src="script.js"></script>
</body>
</html>
style.css
#tetris-canvas {
    border: 2px solid black;
    background-color: #f0f0f0;
}
script.js
const tetrominoes = {
    'I': [
        [0, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 0, 0, 0],
        [0, 0, 0, 0]
    ],
    'J': [
        [1, 0, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'L': [
        [0, 0, 1],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'O': [
        [1, 1],
        [1, 1]
    ],
    'S': [
        [0, 1, 1],
        [1, 1, 0],
        [0, 0, 0]
    ],
    'T': [
        [0, 1, 0],
        [1, 1, 1],
        [0, 0, 0]
    ],
    'Z': [
        [1, 1, 0],
        [0, 1, 1],
        [0, 0, 0]
    ]
};

const rows = 20;
const columns = 10;
const gameField = Array.from({ length: rows }, () => Array(columns).fill(0));
const WIDTH = 300;
const HEIGHT = 600;
const canvas = document.getElementById('tetris-canvas');
const context = canvas.getContext('2d');

canvas.width = WIDTH;
canvas.height = HEIGHT;

function createTetromino() {
    const keys = Object.keys(tetrominoes);
    const randomTetromino = tetrominoes[keys[Math.floor(Math.random() * keys.length)]];
    const currentTetromino = {
        shape: randomTetromino,
        x: Math.floor(columns / 2) - Math.floor(randomTetromino[0].length / 2), // columns を使用
        y: 0
    };

    if (isColliding(currentTetromino.x, currentTetromino.y, currentTetromino.shape)) {
        return null; // 衝突が発生した場合はnullを返してゲームオーバーを示す
    }
    return currentTetromino;
}


function moveTetromino(deltaX, deltaY) {
    let newX = currentTetromino.x + deltaX;
    let newY = currentTetromino.y + deltaY;

    if (!isColliding(newX, newY, currentTetromino.shape)) {
        currentTetromino.x = newX;
        currentTetromino.y = newY;
    } else if (deltaY > 0) { // Y方向に移動しようとしたとき(落下時)に衝突があれば固定する
        fixTetromino();
    }
}


function dropTetromino() {
    moveTetromino(0, 1);
}

function isColliding(x, y, shape) {
    for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[row].length; col++) {
            if (shape[row][col] !== 0) {
                let newX = x + col;
                let newY = y + row;

                if (newX < 0 || newX >= columns || newY >= rows) {
                    return true;
                }

                if (newY < 0) {
                    continue;
                }

                if (gameField[newY][newX] !== 0) {
                    return true;
                }
            }
        }
    }
    return false;
}

function rotateTetromino() {
    const originalShape = currentTetromino.shape;
    const rotatedShape = [];

    // テトリミノを回転
    for (let x = 0; x < originalShape[0].length; x++) {
        rotatedShape[x] = [];
        for (let y = 0; y < originalShape.length; y++) {
            rotatedShape[x][y] = originalShape[y][x];
        }
    }

    const newShape = rotatedShape.reverse();

    // 衝突判定
    if (!isColliding(currentTetromino.x, currentTetromino.y, newShape)) {
        currentTetromino.shape = newShape;
    }
}

function fixTetromino() {
    for (let row = 0; row < currentTetromino.shape.length; row++) {
        for (let col = 0; col < currentTetromino.shape[row].length; col++) {
            if (currentTetromino.shape[row][col] !== 0) {
                gameField[currentTetromino.y + row][currentTetromino.x + col] = 1;
            }
        }
    }

    currentTetromino = createTetromino();
    if (!currentTetromino) {
        gameOver(); // ゲームオーバーの処理を実行
    }

    let linesCleared = checkCompletedLines();
    updateScore(linesCleared);
}

function checkCompletedLines() {
    let linesCleared = 0;

    for (let y = rows - 1; y >= 0; y--) {
        let isComplete = true;

        for (let x = 0; x < columns; x++) {
            if (gameField[y][x] === 0) {
                isComplete = false;
                break;
            }
        }

        if (isComplete) {
            linesCleared++;
            for (let yy = y; yy > 0; yy--) {
                for (let x = 0; x < columns; x++) {
                    gameField[yy][x] = gameField[yy - 1][x];
                }
            }
            for (let x = 0; x < columns; x++) {
                gameField[0][x] = 0;
            }
            y++; // 1行下に移動してもう一度チェック
        }
    }

    return linesCleared;
}

function updateScore(linesCleared) {
    const points = [0, 40, 100, 300, 1200]; // 0, 1, 2, 3, 4行を消去した時のポイント
    score += points[linesCleared];
    

    document.getElementById('score').textContent = score.toString();
}


function drawTetromino(tetromino, offsetX, offsetY, color) {
    const blockSize = canvas.width / columns; // キャンバスの幅を列の数で割った値をブロックサイズとする

    tetromino.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = color;
                context.fillRect(
                    (x + offsetX) * blockSize, 
                    (y + offsetY) * blockSize, 
                    blockSize, 
                    blockSize
                );
            }
        });
    });
}

function drawGameField() {
    const blockSize = canvas.width / columns;
    for (let y = 0; y < rows; y++) {
        for (let x = 0; x < columns; x++) {
            if (gameField[y][x] !== 0) {
                context.fillStyle = 'grey'; // 設置されたブロックの色を設定
                context.fillRect(x * blockSize, y * blockSize, blockSize, blockSize);
            }
        }
    }
}

let animationFrameId; // アニメーションフレームのIDを保存する変数

function gameOver() {
    // ゲームオーバー時の処理
    // ゲームオーバーメッセージの表示
    document.getElementById('gameOverMessage').style.display = 'block';
    // ゲームを停止するために、requestAnimationFrameの呼び出しを停止
    cancelAnimationFrame(animationFrameId);
}

let score = 0;
let lastUpdateTime = 0;
let dropInterval = 1000; // 1秒ごとにテトリミノを落下させる
let currentTetromino = createTetromino();

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height); // キャンバスをクリア
    context.fillStyle = '#f0f0f0';
    context.fillRect(0, 0, canvas.width, canvas.height);

    drawGameField(); // ゲームフィールドのブロックを描画
    drawTetromino(currentTetromino.shape, currentTetromino.x, currentTetromino.y, 'blue'); // 現在のテトリミノを描画
}

function update(time = 0) {
    const deltaTime = time - lastUpdateTime;

    if (deltaTime >= dropInterval) {
        dropTetromino();
        lastUpdateTime = time - (deltaTime % dropInterval);
    }

    draw();
    animationFrameId = requestAnimationFrame(update);
}

document.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowLeft') {
        moveTetromino(-1, 0);
    } else if (event.key === 'ArrowRight') {
        moveTetromino(1, 0);
    } else if (event.key === 'ArrowDown') {
        dropTetromino();
    } else if (event.key === 'ArrowUp') {
        rotateTetromino();
    }
});

document.addEventListener('DOMContentLoaded', () => {
    update();
});

ChatGPT「素晴らしいです!テトリスゲームが完成したとのこと、おめでとうございます!ここまでのプロセスは学習にとても良い経験だったと思います。プログラミングには、問題の特定、ロジックの実装、そしてデバッグという重要なステップが含まれており、これらを経験することで、より一層スキルが向上します。

今回作成したテトリスゲームには、基本的なゲームプレイのロジック、スコアリング、ゲームオーバーの処理が含まれており、これらはゲームを作る上での基礎となります。さらなる改善や新しい機能の追加にも挑戦してみてください。例えば、次のような機能が考えられます:

  • レベルアップによる落下速度の増加
  • 操作性の向上(ソフトドロップ、ハードドロップ、テトリミノのホールドなど)
  • サウンドエフェクトやバックグラウンドミュージック
  • ゲーム再開のためのリスタート機能
  • ハイスコアの保存と表示

もし何か新しい機能を追加する際や、さらにアドバイスが必要な場合は、いつでもサポートを提供できるようにここにいます。プログラミングの旅を続けて、素晴らしいゲームを作り上げてください!」

  1. RWWW;; は試行錯誤の途中で定めていたルールに従っているコマンドのため現在のものとバリデーションが異なっております。検証当時正しいコマンドです。

  2. CLIの引数指定機能は clap を使っています、構造体に属性風マクロを付けるだけでオプション引数を設けられるのでとてもオススメです!

  3. 当初筆者はプログラミングを一切知らない体で全部作らせたかったのですが、途中で沼った際に時間がかかりそうだったため、修正が必要そうな箇所の指摘を数回だけ行いました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0