下学上達とポンのクローン
下学上達
「下学上達」という四字熟語が、数年間ずっと僕のパソコン画面に貼ってあった。ちょっとマイナーな言葉だけど、「上達するためには、基本の基本から学ばなければならない」という意味らしい。
人間の基本的な学び方は「真似」だ。赤ちゃんは大人の真似をするし、音楽家はクラシックを弾くし、美術を学ぶ人は名画を模写する。「真似は最も誠実なお世辞である」という言葉があるなら、「真似は実力を磨く最良の方法である」という言葉があってもいい気がする。
ポンとは?
1970年代の前半、アラン・アルコーンという社員が、上司のノーラン・ブッシュネルから課題を出された。「マグナボックス・オデッセイ」という当時の人気ゲーム機にあるスポーツゲームを真似して作れ、という内容だった。
アルコーンの努力と実力のおかげで、その課題から生まれたのが「ポン」というゲーム。そして、それを出したブッシュネルの会社アタリは、後のゲームブームの先駆者となる。
ある意味、ポンは最初からクローンだったとも言える。ポンはマグナボックスの卓球ゲームを真似し、その卓球ゲームは実際の卓球をデジタル化したもの。そして卓球自体も、もともとはテニスの代替として生まれたスポーツらしい。
だから僕がポンを真似して作るのは、ある意味ポンの精神をちゃんと受け継いでいると思う。
どうしてこのアイデアを思いついたの?
数年前、アメリカのハーバード大学が「CS50 ゲーム開発入門」というコースをネットに公開していた。Luaという言語を使ってレトロゲームを作り直しながら、ゲームの仕組みを学んでいく授業だった。
ただ、Luaにはあまり興味がなかったし、当時は忙しかったのでスルーした。
でも、やっぱり気になっていた。Luaじゃなくても、別の言語で作ってみたら、今勉強している言語の理解も深まるし、動画を見るだけじゃなくて、実際にゲームの中身が分かるんじゃないかと思った。まさに一石二鳥。
今のスキルと状況
そして今に至るわけだけど、C++ と SFML の経験はほとんどゼロ。LearnCPP.comをちょっと読んだだけで、YouTubeでSFMLとVisual Studioの連携方法を見た程度。
まあ、一応アメリカ人だから、意味のない自信は国民的趣味みたいなもんだ。
🔗 プロジェクトリンク
一緒に進めたり、コードを確認したいですか? 👉 GitHub リポジトリ: BikuraStudios/Pong-Clone
-
SFML 3.0 と Visual Studio 2022 を使用して構築。
-
1週間の夜間作業で約15時間。
開発の詳細
ウィンドウの設定
ゲームウィンドウを起動して実行するのが最初。これはほとんどチュートリアルのコピペなので、"独自のプログラミング"とは言いすぎかも。
sf::RenderWindow window(sf::VideoMode(800, 600), "Pong");
window.setFramerateLimit(60);
パドル
シンプルな白い長方形を描いて画面上に配置。
sf::RectangleShape paddle(sf::Vector2f(10.f, 100.f));
paddle.setPosition(50.f, 250.f);
paddle.setFillColor(sf::Color::White);
(マジックナンバーがもう忍び寄ってきてる...)
移動
フレームレートに縛られない入力処理のために、deltaTime
を使って速度を掛ける。
if (sf::Keyboard::isKeyPressed(sf::Keyboard::W))
paddle.move(0.f, -paddleSpeed * deltaTime);
if (sf::Keyboard::isKeyPressed(sf::Keyboard::S))
paddle.move(0.f, paddleSpeed * deltaTime);
ボールの仕組み
ボールもパドルと同様に、定義・配置・速度設定。
sf::CircleShape ball(10.f);
ball.setPosition(400.f, 300.f);
sf::Vector2f ballVelocity(200.f, 200.f);
ball.move(ballVelocity * deltaTime);
※ちなみに僕のコードでは ballVelocity
が技術的に壊れてる。開始方向をランダム化しようとしたけど、乱数のシード値を設定していないから、毎回同じ方向に動く。全然ランダムじゃない。
衝突検出
ここからが難関だった。
エラーを出さない衝突関数を作るために、YouTubeとフォーラムを4時間くらい行ったり来たり。Copilotも役に立たず、存在しない関数を幻覚で提案してくるAIとバトル。
最終的に、SFML 3.0で当たり判定の書き方が変わっていたことに気づく。
以前は:
if (playerRect.getGlobalBounds().intersects(targetRect.getGlobalBounds()))
// Collision
現在は:
if (playerRect.getGlobalBounds().findIntersection(targetRect.getGlobalBounds()))
// Collision
もっと早く気づけよって話だけど、これも経験。
ちなみに、このとき作ったDVDスクリーンセーバーはこちら:
最終的には、基本的なAABB(軸平行境界ボックス)衝突に落ち着いた。完璧じゃないけど、とりあえず動く。いや、ある程度と言った方がいいかな。ボールが角に当たってロケットみたいに加速することもあるけど...
sf::FloatRect leftPaddleBounds = paddle.getGlobalBounds();
sf::FloatRect rightPaddleBounds = paddle2.getGlobalBounds();
sf::FloatRect ballBounds = ball.getGlobalBounds();
if (const std::optional intersection = ballBounds.findIntersection(leftPaddleBounds)) {
sound_leftPaddle.play();
ballVelocity.x *= -1.1f;
}
if (const std::optional intersection = ballBounds.findIntersection(rightPaddleBounds)) {
sound_rightPaddle.play();
ballVelocity.x *= -1.1f;
}
スコアリングシステム
ボールが画面外に出たら、スコアを更新してリセット。
if (ball.getPosition().x < 0.f) {
player2Score += 1;
resetBall();
}
if (ball.getPosition().x > window.getSize().x) {
player1Score += 1;
resetBall();
}
表示用のテキストも更新。
player2ScoreString = std::to_string(player2Score);
scoreTextLeft.setString(player2ScoreString);
player1ScoreString = std::to_string(player1Score);
scoreTextRight.setString(player1ScoreString);
ポーズ機能
ちゃんとしたステートマシンを作るべきだけど、とりあえずブール値で。
bool isPaused = false;
if (!isPaused) {
// ゲームループ
}
if (sf::Keyboard::isKeyPressed(sf::Keyboard::P)) {
isPaused = !isPaused;
}
エレガントではないけど、まあ動く。
ゲームリセット
どちらかが10ポイント取ったらリセットを許可する。
if (startNewGame) {
player1Score = 0;
player2Score = 0;
// スコアテキストを更新
// ポジションをリセット
ball.setPosition({400.f, 300.f});
paddle.setPosition({50.f, 250.f});
paddle2.setPosition({725.f, 250.f});
// 速度を再設定
directionX = randomDirection();
directionY = randomDirection();
ballVelocity = {200.f * directionX, 200.f * directionY};
startNewGame = false;
}
今後は、いつでもできるリセットも加えたい。
昆虫学者のスケッチブックよりもバグが多い
バグリスト(網羅的ではない):
-
ウィンドウの作成や入力のドキュメントが古い
-
パドルが画面外にスクロールする
-
ボールが終端速度で画面外に飛ぶ
-
{}
のない複数行if
文 -
main()
内に関数定義 -
sf::Vector2f
の引数で混乱 -
バウンディングボックスの実装が不十分
-
フォントファイルの読み込みが困難
-
スコアテキストが更新されない
-
状態遷移が複雑すぎる
初期のロジックが特にひどい例:
if (keyPressed->scancode == sf::Keyboard::Scancode::Space) {
if (Pause == true)
Pause = false;
if (Pause == false)
Pause = true;
}
最終的にはこう簡略化:
if (keyPressed->scancode == sf::Keyboard::Scancode::Space)
Pause = !Pause;
コードレビューの要約
プログラミング仲間がいないので、ChatGPTにコードレビューしてもらった。結果はこちら:
主要な問題:
-
main()
が600行以上で分割されていない -
ゲームロジック、入力、レンダリングすべてが
main()
にある -
繰り返しコード(WET)
-
非効率なイベント処理
小さな問題:
-
コメントアウトが多すぎ
-
マジックナンバーが多い
-
アセット読み込みエラーの処理がない
-
トグルがないのでデバッグが大変
勉強になったかな?
全体的に見て、これは最初のプロジェクトとして素晴らしかった。たくさんのことを学べたし、ちゃんとプレイできる形に仕上げられた。
それ以上に大事なのは、ゲームを始めから最後まで作り切れるという自信が持てたこと。この自信だけでも、この経験は十分価値があったと思う。
今週はこれで終わり。
来週は Flappy Bird について書くつもり。
さよなら。
Discussion