💡

下学上達とポンのクローン

に公開

下学上達

「下学上達」という四字熟語が、数年間ずっと僕のパソコン画面に貼ってあった。ちょっとマイナーな言葉だけど、「上達するためには、基本の基本から学ばなければならない」という意味らしい。

人間の基本的な学び方は「真似」だ。赤ちゃんは大人の真似をするし、音楽家はクラシックを弾くし、美術を学ぶ人は名画を模写する。「真似は最も誠実なお世辞である」という言葉があるなら、「真似は実力を磨く最良の方法である」という言葉があってもいい気がする。

ポンとは?

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