2026-06-22 00:26:42
諏訪子
c++
raylib
gamedev
easy

【C++】初めてのゲーム作成ガイド

経験豊富な「システムエンジニア」で、人生で初めてのゲームを作りたいと思っているが、何処から始めれば良いのか分からない。
まあ、諦めなさい。
ゲームなんて作れやしない。
冗談です!
出来るだけ分かり易く、初めてのゲームの作り方を紹介します。

「でも、他の誰も理解出来ない様な非常に高度な課題を議論している」と思っているかもしん。
心配しないで下さい。
今回は出来るだけ初心者向けにします!
但し、1つだけお願いがあります。
此の記事を注意深く読み、テキストエディターやIDEでコードの全ての行を自分で書き直して下さい。
此れがゲームを作る事を本当に学ぶ唯一の方法です。

ゲーム

シンプルなスネークゲームを作る事にしました。
スネークは非常に良い初心者向けゲームです。
作るのが難しくない一方で、後により複雑なゲームに必要な全ての基礎を教えてくれます。
ゲームプレイループ、衝突検出、ランダム性、背景レンダリング、シーン管理、スコアシステム、フォント、リアルタイムユーザー入力等、スネークには多くの要素が含まれており、此れらは地球上の全てのゲームに存在します。

完成したプロジェクトは此方で確認出来ます。

依存関係

以下のツールが必要です:

注:日本語版のページでは古い情報が表示される為、英語版を使用しました。

執筆時点で、Raylibの最新バージョンは6.0です。
Microsoft Githubのリリースページを開くと、「linux_amd64」、「macos」、「win32_msvc16」等の多くのバージョンが見つかります。
其れらをダウンロードしないで下さい。
代わりに、「Source code (zip)」又は「Source code (tar.gz)」をダウンロードして下さい。
簡単なアクセス用: https://github.com/raysan5/raylib/archive/refs/tags/6.0.zip

「スネーク」という名前の空のディレクトリを作成します。
其の中に以下のディレクトリを作成します:

  • src
  • include
  • deps

srcディレクトリには、main.ccを除く全てのソースコードを置きます。
main.ccはプロジェクトのルートに置きます。
includeディレクトリには全てのヘッダーファイルを置きます。
此れにより、コード内に綺麗なインターフェースを作成します。
depsディレクトリにはRaylibを置きます。

RaylibのZIP又はTarballを解凍し、raylib-6.0raylibにリネームし、以下のファイルとディレクトリを削除します:

$ rm -rf BINDINGS.md CONTRIBUTING.md CONVENTIONS.md HISTORY.md ROADMAP.md SECURITY.md build.zig build.zig.zon CHANGELOG LICENSE logo projects examples

コンパイルがまだ動作するかどうかをテストしましょう。

$ cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_EXAMPLES=OFF
-- Testing if -Werror=pointer-arith can be used -- compiles
-- Testing if -Werror=implicit-function-declaration can be used -- compiles
-- Testing if -fno-strict-aliasing can be used -- compiles
-- Using raylib's GLFW
-- Including X11 support
-- Audio Backend: miniaudio
-- Building raylib static library
-- X11 support enabled for raylib
-- Generated build type: Release
-- Compiling with the flags:
--   PLATFORM=PLATFORM_DESKTOP
--   GRAPHICS=GRAPHICS_API_OPENGL_33
-- Configuring done (20.6s)
-- Generating done (1.9s)
-- Build files have been written to: /home/suwako/w/dev/learn/Snake/deps/raylib/build
$ cmake --build build
[  3%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/context.c.o
[  6%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/init.c.o
[ 10%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/input.c.o
[ 13%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/monitor.c.o
[ 16%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/platform.c.o
[ 20%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/vulkan.c.o
[ 23%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/window.c.o
[ 26%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/egl_context.c.o
[ 30%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/osmesa_context.c.o
[ 33%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/null_init.c.o
[ 36%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/null_monitor.c.o
[ 40%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/null_window.c.o
[ 43%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/null_joystick.c.o
[ 46%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/posix_module.c.o
[ 50%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/posix_time.c.o
[ 53%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/posix_thread.c.o
[ 56%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/x11_init.c.o
[ 60%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/x11_monitor.c.o
[ 63%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/x11_window.c.o
[ 66%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/xkb_unicode.c.o
[ 70%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/glx_context.c.o
[ 73%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/linux_joystick.c.o
[ 76%] Building C object raylib/external/glfw/src/CMakeFiles/glfw.dir/posix_poll.c.o
[ 76%] Built target glfw
[ 80%] Building C object raylib/CMakeFiles/raylib.dir/raudio.c.o
[ 83%] Building C object raylib/CMakeFiles/raylib.dir/rcore.c.o
[ 86%] Building C object raylib/CMakeFiles/raylib.dir/rmodels.c.o
[ 90%] Building C object raylib/CMakeFiles/raylib.dir/rshapes.c.o
[ 93%] Building C object raylib/CMakeFiles/raylib.dir/rtext.c.o
[ 96%] Building C object raylib/CMakeFiles/raylib.dir/rtextures.c.o
[100%] Linking C static library libraylib.a
[100%] Built target raylib

動作します。

Neovimやclangdにアクセス出来る他のテキストエディタを使用している場合は、以下の.clangdファイルをプロジェクトのルートに配置して下さい:

CompileFlags:
  Add:
    - -I./include
    - -I./deps/raylib/src
    - -L./build/deps/raylib
    - -std=c++20
    - -Wno-pragma-once-outside-header

Raylibとは

Raylibは基本的に、よりモダンなSDLやSFMLです。
非常に野心的なプロジェクトですが、非常に使い易く、優れたドキュメントとサポート、幅広いプラットフォームをサポートしており、使うのもとても楽しいです。
過去には初心者向けにSDLをお勧めしていましたが、今はRaylibをお勧めします。

CMake

始める前に、CMakeファイルを作成します。
CMakeとは?
CMakeはmakefileを作成します!
makefileとは?
makefileはプロジェクトを作成します!
何!?どうしてこうなった!?

此れが長年CMakeが好きになれなかった理由です。
然し、実プロジェクト(現在は全てクローズドソースですが、一部は後でオープンソースに成る予定)で使用した後、クロスプラットフォーム、クロスエディターのコンパイルに優れたツールである事が分かりました。
特にVisual Studioを使用する場合、手動のプロパティ編集やファイル管理の面倒な作業がなく成り、CMakeがチーム全体の全てのプロジェクトで一貫して処理してくれます。
此れにより、其の様な無駄な時間をプログラミングに振り向ける事が出来ます。
欠点は、バージョンごとに大きく変化する非常に異質な構文です。
Neovim、Emacs、Visual Studio Codeで作業する場合でも、毎回全てをコンパイルするのではなく、変更されたコードのみをコンパイルする点が優れています。
此れにより、大規模プロジェクトでの反復が大幅に高速化されます。

幸いな事に、プロジェクトの開始時に1回設定するだけで、正しく設定すれば忘れてしまって構いません。

cmake_minimum_required(VERSION 3.16...3.28)
project(snake LANGUAGES CXX VERSION 1.0.0)
set(CMAKE_CXX_STANDARD 20)

if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  set(CMAKE_BUILD_TYPE "Debug" CACHE STRING "デバッグ又はリリース" FORCE)
endif()

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(BUILD_EXAMPLES OFF CACHE BOOL "Raylib例のプロジェクトを無効化" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "静的リンク" FORCE)

if(MSVC)
  set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Release>:Release>")
  set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
  add_compile_options(/utf-8)
endif()

add_subdirectory(deps/raylib)

file(GLOB_RECURSE SNAKE_SRC CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cc")
file(GLOB_RECURSE SNAKE_INC CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/include/*.hh")

add_executable(snake main.cc ${SNAKE_SRC} ${SNAKE_INC})
set_target_properties(snake PROPERTIES CXX_STANDARD 20)

if(UNIX)
  message(STATUS "${CMAKE_SYSTEM_NAME}で静的リンクが不可能為、動的リンクで伺います。")
endif()

if(CMAKE_BUILD_TYPE STREQUAL "Debug" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
  set(PRODUCTION_BUILD OFF CACHE BOOL "リリース版?" FORCE)
else()
  set(PRODUCTION_BUILD ON CACHE BOOL "リリース版?" FORCE)
  if(WIN32)
    set(INSTALL_BINDIR "C:/Program Files/TechnicalSuwako/Snake")
  else()
    set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR})
  endif()
endif()

install(TARGETS snake
  RUNTIME DESTINATION ${INSTALL_BINDIR}
  PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE
  COMPONENT Runtime
)

if(MSVC)
  set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT snake)
  target_compile_definitions(snake PUBLIC _CRT_SECURE_NO_WARNINGS)

  if(PRODUCTION_BUILD)
    set_target_properties(snake PROPERTIES LINK_FLAGS "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup")
  endif()
elseif(MINGW)
  if(PRODUCTION_BUILD)
    target_link_options(snake PRIVATE -mwindows)
  endif()
elseif(APPLE OR UNIX)
  if(PRODUCTION_BUILD)
    target_link_options("${CMAKE_PROJECT_NAME}" PRIVATE -s)
  endif()
endif()

target_link_libraries(snake PRIVATE raylib)

target_include_directories(snake PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")

ステップバイステップで、此処では必要な最小CMakeバージョン、プロジェクト名、C++標準を定義します。
静的リンクを強制し、Raylibのサンプルがビルドされない様にします(削除しましたが、RaylibのCMakeListsファイルはデフォルトで其れらをビルドしようとします)。
独自のincludeとsourceディレクトリを設定し、Windowsで再び静的リンクを確保しますが、その他OSでは動的リンクします。
インストールディレクトリも設定し、実行を許可するパーミッションを設定します。
Windowsではリリースビルドでターミナルウィンドウを削除し、他の全てのOSでstripします。
最後にRaylibにリンクします。

未だ作成していない場合は、プロジェクトのルートにmain.ccを作成します:

$ touch main.cc

Visual Studio 2022を使用している場合は、CMakeを以下の様に実行します:

$ cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Debug

其処からbuildディレクトリを開き、snake.slnを開いて作業出来ます。
Visual Studio内で新しいファイルを作成する代わりに、Windows Explorerで新しいファイルを作成するか、PowerShellでtouchを使用してファイルをより速く作成します。
其の後、CTRL + Bを押してCMakeビルドを呼び出すと、ソリューションエクスプローラーが自動的に更新されます。

他の全ての人にとって、コマンドは次のとおりです:

$ mkdir -p build && cd build
$ cmake .. -DCMAKE_BUILD_TYPE=Debug
$ make

main.cc

#include <iostream>

int main (void) {
  std::cout << "ちんこ" << std::endl;

  return 0;
}

$ cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Debug
-- The CXX compiler identification is MSVC 19.43.34810.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: C:/Program Files/Microsoft Visual Studio/2022/Professional/VC/Tools/MSVC/14.43.34808/bin/Hostx64/x64/cl.exe - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- The C compiler identification is MSVC 19.43.34810.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: C:/Program Files/Microsoft Visual Studio/2022/Professional/VC/Tools/MSVC/14.43.34808/bin/Hostx64/x64/cl.exe - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Performing Test COMPILER_HAS_THOSE_TOGGLES
-- Performing Test COMPILER_HAS_THOSE_TOGGLES - Failed
-- Testing if -Werror=pointer-arith can be used -- Failed
-- Testing if -Werror=implicit-function-declaration can be used -- Failed
-- Testing if -fno-strict-aliasing can be used -- Failed
-- Using raylib's GLFW
-- Performing Test CMAKE_HAVE_LIBC_PTHREAD
-- Performing Test CMAKE_HAVE_LIBC_PTHREAD - Failed
-- Looking for pthread_create in pthreads
-- Looking for pthread_create in pthreads - not found
-- Looking for pthread_create in pthread
-- Looking for pthread_create in pthread - not found
-- Found Threads: TRUE
-- Including Win32 support
-- Audio Backend: miniaudio
-- Building raylib static library
-- Generated build type: Debug
-- Compiling with the flags:
--   PLATFORM=PLATFORM_DESKTOP
--   GRAPHICS=GRAPHICS_API_OPENGL_33
-- Configuring done (16.8s)
-- Generating done (0.3s)
-- Build files have been written to: C:/Users/suwako/dev/learn/Snake/build

ビルドはあたしの環境で成功したので、続行出来ます。

Git

此れ以上進む前に、ローカルのGitリポジトリを設定します。

Microsoft GitHubや他のウェブサイトにプッシュする必要は必ずしもありません。
Gitを使って安全にセーブポイントを設定出来る様にしたいだけです。
此れにより、何か問題が発生した場合に最後に動作したコードにリバート出来、新しい機能を安全に追加する為のブランチを作成出来ます。
此の記事ではデモしませんが、従う良い習慣です。

$ git init
Initialized empty Git repository in C:/Users/suwako/dev/learn/Snake/.git/
$ touch .gitignore


    ディレクトリ: C:\Users\suwako\dev\learn\Snake


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        2026/06/21      2:04              0 .gitignore

.gitignoreに:

build

buildディレクトリを追加します。
Git履歴に此れを含めたくありません。
内容が大き過ぎるとGitが大幅に遅くなる為です。

ウィンドウの作成

GUIプログラミングで最も基本的な事から始めましょう:ウィンドウを開く事です。

#include <iostream>
#include <raylib.h>

int main(void) {
  constexpr int screenWidth = 1600;
  constexpr int screenHeight = 900;

#if PRODUCTION_BUILD == 1
  SetTraceLogLevel(LOG_NONE);
#endif
  InitWindow(screenWidth, screenHeight, "Snake");
  SetExitKey(KEY_NULL);
  SetTargetFPS(120);

  bool running = true;

  while (running && !WindowShouldClose()) {
    if (IsKeyPressed(KEY_Q)) {
      running = false;
    }

    BeginDrawing();
    ClearBackground(MAGENTA);
    EndDrawing();
  }

  CloseWindow();

  return 0;
}

ビルドして実行すると:スネーク

動作します!

Raylibの構文は非常に分かり易いです。
然し、簡単に説明すると:

  1. 画面の幅と高さの変数を設定します。
  2. リリースビルドでのみトレースログを無効にします。
  3. ウィンドウをリサイズ可能にします。
  4. 新しいウィンドウを作成します。
  5. ウィンドウが閉じない様にします。
  6. フレームレートを120 FPSに設定します。
  7. ゲームが未だ実行中かどうかを確認するbooleanを作成します。
  8. ゲームループを作成し、其の中でQキーを押すとrunningをfalseに設定し、ループを終了します。
  9. ウィンドウにマゼンタ色を描画します。
  10. 終了時にウィンドウを閉じます。

デフォルトでは、Escapeボタンを押すとRaylibはウィンドウを閉じます。
此れは、「メインメニューに戻る」やポーズ画面を実装したい場合に問題になる可能性があります。
此れらは通常Escapeにマッピングされます。
running booleanについては、Raylibにはウィンドウを閉じるべきかどうかを確認する関数しかないという馬鹿げた理由で、閉じる様に設定する物はありません。
従って、独自のbooleanを作成し、両方がtrueである事を確認する必要があります。

Visual Studioのヒント

覚えておくべき事があります。
C++について考えると、直感的にOOPの方法でC++を書き、RAII原則に従い、何処でもメモリを解放するでしょう。
此れはセキュリティとメモリ安全性が重要な分野では良いC++です。
然し、ゲーム開発では、実際にはC++をコンテナ(文字列、ベクター、マップ等)と名前空間を持つCの様に書く方が遥に望ましいです。
ゲーム開発では、速度が全てです。
従って、オンラインマルチプレイヤーゲームやインターネット接続を伴う其の他の機能がない限り、セキュリティは懸念事項ではありません。

OOPはコンパイル時間を遅くし、パフォーマンスのボトルネックを引き起こします。
OSは既にリソースの解放を処理してくれるので、クラッシュが発生しない限り、手動でメモリを解放しなくても問題ありません。

理由は、プレイヤーがウィンドウを閉じると、直ぐに閉じる事を期待するからです。
閉じる際に先ずメモリから全てを解放する必要がある場合、ウィンドウは閉じる際に数秒間フリーズした様に見え、プレイヤーはクラッシュしたと思って強制的にウィンドウを終了しようとします。
其の間に、ウィンドウを閉じるとゲームの進行状況を保存するオートセーブ機能があるとしたらどうでしょうか?
プレイヤーが強制的にウィンドウを閉じると、進行状況は保存されず、Steamページに悪いレビューを書かれ、進行状況が保存されなかった事に怒るでしょう。
従って、其の場合はオペレーティングシステムにメモリ管理を任せましょう。

もう1つのヒントは、例外をスローしない事です。
ゲーム機では此れは完全に不可能ですが、PCではボトルネックを引き起こす為、多くのゲームスタジオは其れを完全に禁止するか、厳しく制限しています。
例外をスローする事はシステムプログラミングには非常に便利ですが、ゲームプログラミングには適していません。
代わりに、アサートが推奨されます。

又、可能であれば、ヒープ割り当てを必要な物だけに最小限に抑えて下さい。
メモリ割り当ては遅いので、スタック上で実行する方が遥に高速です。
但し、C++ではメモリを一切割り当てない事は不可能です。
標準ライブラリで冴えバックグラウンドで其れを行います。
Cでは可能ですが。

ポリモーフィズムはどんな犠牲を払っても避けて下さい。
ボトルネックを引き起こすだけでなく、理由もなくコードを複雑にするだけです。

最後に、ヘッダーファイルにインクルードを置く事を出来るだけ避けて下さい。
コンパイル時間を遅くし、ヘッダーファイルが互いにループする形でコンパイラエラーを引き起こす可能性があります。
2つのファイルが互いに依存する必要がある場合は、代わりに前方宣言を使用して下さい。

又、Visual Studioを使用している場合は、CTRL + Kを押してからCTRL + Oを押してみて下さい。
此れによりヘッダーファイルとソースファイルの間を切り替える事ができ、非常に時間を節約出来ます。
複数のカーソルを生成するには、Shift + Alt + Up/Downを使用出来ます。
行をコメントアウトするには、CTRL + /を使用します。

シーン管理

次に、必要なシーンを確立する必要があります。

  • タイトル画面
  • メインゲームプレイ
  • ゲームオーバー

ゲームオーバー画面では、ゲームを再起動するかゲームを終了するかのオプションをプレイヤーに提供します。
タイトル画面に戻るオプションを提供する本当の意味はありません。
タイトル画面がする全ての事は、プレイヤーにゲームの準備をする時間を与え、素敵な方法で歓迎する事だからです。
ゲームオーバー画面はすでに此の機能を果たしていますが、歓迎の仕方があんま良くないだけです。

includesrcsceneディレクトリを作成します。
其の中にtitle.{hh,cc}gamemain.{hh,cc}gameover.{hh,cc}を作成します。

$ mkdir -p src/scene include/snake/scene
$ touch include/snake/scene/{title,gamemain,gameover}.hh
$ touch src/scene/{title,gamemain,gameover}.cc

各ヘッダーファイルに:

#pragma once

namespace snake::scene {
  struct TitleScreen {
    bool Init();
    bool Update();
    void Close();
  };
} // namespace snake::scene

各ソースファイルに:

#include <snake/scene/title.hh>
#include <raylib.h>

namespace snake::scene {
  bool TitleScreen::Init() {
    return true;
  }

  bool TitleScreen::Update() {
    ClearBackground(MAGENTA);
    return true;
  }

  void TitleScreen::Close() {
  }
} // namespace snake::scene

gameoverとgamemainについても同様に繰り返しますが、ヘッダーファイルと構造体名を適宜変更して下さい。
但し、GameMainではClearBackground(MAGENTA);ClearBackground(BLUE);に変更しましょう。

更に:

$ touch include/snake/snake.hh src/snake.cc

#pragma once

namespace snake {
  enum Scene {
    TitleScreen,
    GameMain,
    GameOver,
  };

  extern Scene s;
  extern bool IsRunning;

  bool Init();
  bool Update();
  void Close();
} // namespace snake

各ソースファイルに:

#include <snake/snake.hh>

#include <snake/scene/title.hh>
#include <snake/scene/gamemain.hh>
#include <snake/scene/gameover.hh>

namespace snake {
  bool IsRunning = true;
  Scene s = TitleScreen;
  scene::TitleScreen ts;
  scene::GameMain gm;
  scene::GameOver go;

  bool Init() {
    switch (s) {
      case GameMain:
        return gm.Init();
        break;
      case GameOver:
        return go.Init();
        break;
      default:
        return ts.Init();
        break;
    }
  }

  bool Update() {
    switch (s) {
      case GameMain:
        return gm.Update();
        break;
      case GameOver:
        return go.Update();
        break;
      default:
        return ts.Update();
        break;
    }
  }

  void Close() {
    switch (s) {
      case GameMain:
        gm.Close();
        break;
      case GameOver:
        go.Close();
        break;
      default:
        ts.Close();
        break;
    }
  }
} // namespace snake

シーンをenumにする理由は、タイトル画面とメインゲームの間に著作権画面等の別のシーンを追加する事にした場合、単にCopyright値を追加し、3つの関数をCopyrightのケースを追加するだけで調整出来、他の何も変更する必要がないからです。

此の方法でmain.ccに以下を置く事が出来ます:

...
#include <snake/snake.hh>

int main(void) {
...
  bool changeScene = false;
  snake::Init();
  while (running && !WindowShouldClose()) {
...
    if (IsKeyPressed(KEY_ENTER)) {
      changeScene = true;
      snake::Close();
      snake::s = snake::GameMain;
    }

    if (changeScene) {
      snake::Init();
      changeScene = false;
    }

    BeginDrawing();
    snake::Update();
    EndDrawing();
  }

  snake::Close();
  CloseWindow();

  return 0;
}

今、再度コンパイルして実行すると、再びマゼンタの画面が表示されます。
Enterを押すと画面が青に変わります。

此れでシーン管理システムが動作している事が確認出来ました。
然し、単色の画面を見つめるだけでは面白くありません。
では、本物のゲームを作りましょう。

changeScene booleanは、古いシーンを適切に閉じて新しいシーンを1度だけ開く事を保証します。
然し、此れはバグです。
Enterを押すと、いつでもゲームプレイループを効果的に再起動出来るからです。
従って、此れをタイトル画面に移動する方が良いアイデアです。

従って、main.ccファイルは次の様に成ります:

#include <iostream>
#include <raylib.h>
#include <snake/snake.hh>

int main(void) {
  constexpr int screenWidth = 1600;
  constexpr int screenHeight = 900;

#if PRODUCTION_BUILD == 1
  SetTraceLogLevel(LOG_NONE);
#endif
  InitWindow(screenWidth, screenHeight, "Snake");
  SetExitKey(KEY_NULL);
  SetTargetFPS(120);

  bool running = true;
  snake::Init();

  while (running && !WindowShouldClose()) {
    if (IsKeyPressed(KEY_Q)) {
      running = false;
    }

    BeginDrawing();
    snake::Update();
    EndDrawing();
  }

  snake::Close();
  CloseWindow();

  return 0;
}

そしてtitle.ccに:

#include <snake/scene/title.hh>
#include <snake/snake.hh>
#include <raylib.h>

namespace snake::scene {
  bool TitleScreen::Init() {
    return true;
  }

  bool TitleScreen::Update() {
    if (IsKeyPressed(KEY_ENTER)) {
      snake::Close();
      snake::s = snake::GameMain;
      snake::Init();
    }

    ClearBackground(MAGENTA);

    return true;
  }

  void TitleScreen::Close() {
  }
} // namespace snake::scene

此れによりbooleanの必要性もなく成りますので、Win-Winです。

ゲームプレイループ

タイトル画面は其のままにしておきます。
此の記事の最後に処理します。
ゲーム開発では、常に最初にメインのゲームプレイループに焦点を当てます。
タイトル画面に必要な機能がある場合は、基本的なプレースホルダーテキストを入れる事が出来ますが、最初はあんま時間をかけないで下さい。
あたし達の場合、唯一の機能はメインのゲームプレイループに移動させる事なので、心配しないで下さい。

gamemain.ccに:

#include <snake/scene/gamemain.hh>
#include <raylib.h>

namespace snake::scene {
  Vector2 gridPos = { 30, 60 };
  constexpr int CELL_SIZE = 48;
  constexpr int MAX_CELLX = 32;
  constexpr int MAX_CELLY = 17;

  struct Snake {
    Vector2 cell;
    Color color;
  };

  Snake snek = { 0 };

  bool GameMain::Init() {
    snek.cell = { 10, 10 };
    snek.color = RED;

    return true;
  }

  bool GameMain::Update() {
    ClearBackground(BLACK);

    // グリッド
    int cX = static_cast<int>(gridPos.x);
    int cY = static_cast<int>(gridPos.y);
    for (int h = 0; h <= MAX_CELLY; ++h) {
      int y = cY + h * CELL_SIZE;
      DrawLine(cX, y, cX + MAX_CELLX * CELL_SIZE, y, RAYWHITE);
    }
    for (int w = 0; w <= MAX_CELLX; ++w) {
      int x = cX + w * CELL_SIZE;
      DrawLine(x, cY, x, cY + MAX_CELLY * CELL_SIZE, RAYWHITE);
    }

    // プレイヤー
    int pX = static_cast<int>(snek.cell.x) * CELL_SIZE;
    int pY = static_cast<int>(snek.cell.y) * CELL_SIZE;
    DrawRectangle(cX + pX, cY + pY, CELL_SIZE, CELL_SIZE, snek.color);
    DrawText(TextFormat("SNAKE: %.0f, %.0f", snek.cell.x, snek.cell.y), 10, 10, 20, YELLOW);
    DrawText(TextFormat("MAX CELL: %d, %d", MAX_CELLX, MAX_CELLY), 10, 30, 20, YELLOW);

    return true;
  }

  void GameMain::Close() {
  }
} // namespace snake::scene

此処では背景色を黒に変更して目が疲れにくくします。
次にグリッドとプレイヤーを描画しますが、未だ何も動きません。
又、プレイヤーの位置のデバッグテキストも描画します。
スネーク

でも、1600x900のウィンドウはノートパソコンユーザーには本当に理想的ではないかもしん。
800x600の方が適していると思います。
幸いな事に、其れには幾つかの変数を変更するだけで済みます。

main.cc:

...
  constexpr int screenWidth = 800;
  constexpr int screenHeight = 600;
...
...

...
  Vector2 gridPos = { 16, 54 };
  constexpr int CELL_SIZE = 24;
  constexpr int MAX_CELLX = 32;
  constexpr int MAX_CELLY = 22;
...

スネーク

ずっと良い!

然し、今度は此れを動かせる様にする必要があります。
蛇が上下に移動している場合は左右のみ、左右に移動している場合は上下のみを押せる様に、移動方向を記録しておく必要があります。
自分自身にぶつからない様にしたいです。

...
  enum SnakeDir {
    Up,
    Down,
    Left,
    Right,
  };

  struct Snake {
    Vector2 cell;
    Color color;
    SnakeDir dir;
    SnakeDir nextDir;
  };
...
  bool GameMain::Init() {
    snek.cell = { 10, 10 };
    snek.color = RED;
    snek.dir = Right;
    snek.nextDir = snek.dir;

    return true;
  }

  bool GameMain::Update() {
    if (snek.dir == Left || snek.dir == Right) {
      if (IsKeyPressed(KEY_UP)) snek.nextDir = Up;
      else if (IsKeyPressed(KEY_DOWN)) snek.nextDir = Down;
    } else {
      if (IsKeyPressed(KEY_LEFT)) snek.nextDir = Left;
      else if (IsKeyPressed(KEY_RIGHT)) snek.nextDir = Right;
    }

    if ((snek.nextDir == Left && snek.dir == Right)
     || (snek.nextDir == Right && snek.dir == Left)
     || (snek.nextDir == Up && snek.dir == Down)
     || (snek.nextDir == Down && snek.dir == Up)) {
      snek.nextDir = snek.dir;
    }

    if (snek.dir == Left) snek.cell.x -= 1;
    else if (snek.dir == Right) snek.cell.x += 1;
    else if (snek.dir == Up) snek.cell.y -= 1;
    else if (snek.dir == Down) snek.cell.y += 1;
...
  }

原則として、此のコードは動作します。
然し、1つの問題があります:ハードウェアによっては、蛇が速過ぎたり遅過ぎたりします。
あたしのPCでは弾丸の様に速いです。

其れを解決する為に、デルタタイムを紹介したいと思います。
デルタタイムとは?
聞いた事がない!

冗談です。
基本的に、デルタタイムは各ハードウェアで動きが同じ様にスムーズになる事を保証します。
然し、此れはデルタタイムを全てに使用しなければならないという意味ではありません。
自動的な移動にのみ使用するべきです。
ユーザー制御の移動はデルタタイムなしで行うべきです。
そうしないと、プレイヤーは入力ラグを経験し、其れは良くありません。

    float deltaTime = GetFrameTime();
    if (deltaTime > 1.f / 5) deltaTime = 1.f / 5;

    if (snek.dir == Left || snek.dir == Right) {
      if (IsKeyPressed(KEY_UP)) snek.nextDir = Up;
      else if (IsKeyPressed(KEY_DOWN)) snek.nextDir = Down;
    } else {
      if (IsKeyPressed(KEY_LEFT)) snek.nextDir = Left;
      else if (IsKeyPressed(KEY_RIGHT)) snek.nextDir = Right;
    }

    if ((snek.nextDir == Left && snek.dir == Right)
     || (snek.nextDir == Right && snek.dir == Left)
     || (snek.nextDir == Up && snek.dir == Down)
     || (snek.nextDir == Down && snek.dir == Up)) {
      snek.nextDir = snek.dir;
    }

    if (snek.dir == Left) snek.cell.x -= 1 * deltaTime;
    else if (snek.dir == Right) snek.cell.x += 1 * deltaTime;
    else if (snek.dir == Up) snek.cell.y -= 1 * deltaTime;
    else if (snek.dir == Down) snek.cell.y += 1 * deltaTime;

此れで蛇を制御出来る様に成りました。
動きが遅過ぎると思います。
其れを修正するには、deltaTimespeedを掛ける事が出来ます。

    float speed = 2.5f;
    float deltaTime = GetFrameTime() * speed;
...

float speed = 2.5f;を関数の外に移動しましょう。
Initメソッドでspeedを2.5fとして初期化する様にして下さい。
此れにより、ゲームが続くにつれて蛇を徐々に速く動かす事が出来ます。

  float speed;
...
  bool GameMain::Init() {
    speed = 2.5f;
...
  }

もう1つの問題があります。
ゲームを開始し、出来るだけ速く上と左を素早く押すと、蛇は最初に上に行かずに左に曲がります。
此れは問題です。
其れを修正するには、追加のチェックを追加する必要があります:

    int curGridX = static_cast<int>(snek.cell.x);
    int curGridY = static_cast<int>(snek.cell.y);
    static int prevGridX = curGridX;
    static int prevGridY = curGridY;

    if ((curGridX != prevGridX) || (curGridY != prevGridY)) {
      snek.dir = snek.nextDir;
      prevGridX = curGridX;
      prevGridY = curGridY;
    }

そうすると、今度は蛇が上に行ってから左に行きます。
同時に上と左に行っている様に見えますが、蛇が自分を食べるよりはマシです。

恐らく気づいたと思いますが、あたしは最初に全てのイベントを計算し、次に画面に描画しています。
此れが推奨される方法です。
常にロジックを最初に処理し、其の結果を画面に描画する方が良いです。
其の逆ではなく。
スネークの様なシンプルなゲームでは其れ程問題に成りませんが、より複雑なプロジェクトでは、最初に描画してから計算したり、描画しながら計算したりするとラグが発生します。
ハードウェアが考える必要があるという意味でのラグではなく、プレイヤーが入力の数秒後にアクションが発生した事に気づくという意味でのラグです。
ゲームでは、アクションが入力と出来るだけ同期している様に見せる必要があります。

知っておくべきもう1つの事は、計算と計算の間でも順序が重要だという事です。
例えば、移動に関連する衝突は、移動の前ではなく移動の後にチェックする必要があります。
前に行うと、蛇が最初に境界外に移動するのが見え、其の後ゲームオーバーに成ります。
逆の順序にしたいです。

ゲームオーバー

次に、ゲームオーバーを実装しましょう。
蛇が壁にぶつかった場合にゲームオーバー画面を表示する様にします。

gameover.ccに:

#include <snake/scene/gameover.hh>
#include <snake/snake.hh>
#include <raylib.h>

namespace snake::scene {
  bool GameOver::Init() {
    return true;
  }

  bool GameOver::Update() {
    if (IsKeyPressed(KEY_ENTER)) {
      snake::Close();
      snake::s = snake::GameMain;
      snake::Init();
    }

    ClearBackground(BLACK);

    int fontSize = 24;
    int textWidth = MeasureText("GAME OVER", fontSize);
    DrawText("GAME OVER", GetScreenWidth() / 2 - textWidth / 2, GetScreenHeight() / 2 - 30, fontSize, RED);

    fontSize = 16;
    textWidth = MeasureText("PRESS ENTER TO TRY AGAIN", fontSize);
    DrawText("PRESS ENTER TO TRY AGAIN", GetScreenWidth() / 2 - textWidth / 2, GetScreenHeight() / 2 + 30, fontSize, RED);
    return true;
  }

  void GameOver::Close() {
  }
} // namespace snake::scene

gamemain.ccに:

...
#include <snake/snake.hh>
...
  bool isDead = false;
...
    // 壁を打ったかどうか確認
    if ((snek.cell.x < 0 || snek.cell.x > MAX_CELLX)
     || (snek.cell.y < 0 || snek.cell.y > MAX_CELLY)) {
      snake::Close();
      snake::s = snake::GameOver;
      snake::Init();
      return true;
    }
...

食べ物

次は食べ物を実装します。
グリッド上のランダムな場所に常に食べ物がスポーンし、其れを食べると蛇が成長します。
成長部分は次に行います。
食べ物の色はランダムで、其れが蛇の体を決定します。

...
  struct Food {
    Vector2 cell;
    Color color;
  };

  Food food = { 0 };
...
  bool GameMain::Init() {
...
    food.cell = {
      static_cast<float>(GetRandomValue(0, MAX_CELLX - 1)),
      static_cast<float>(GetRandomValue(0, MAX_CELLY - 1)),
    };

    Color foodCol;
    foodCol.r = GetRandomValue(100, 255);
    foodCol.g = GetRandomValue(100, 255);
    foodCol.b = GetRandomValue(100, 255);
    foodCol.a = 255;
    food.color = foodCol;
...
  }
...
  bool GameMain::Update() {
...

    // フード
    int fX = static_cast<int>(food.cell.x) * CELL_SIZE;
    int fY = static_cast<int>(food.cell.y) * CELL_SIZE;
    DrawRectangle(cX + fX, cY + fY, CELL_SIZE, CELL_SIZE, food.color);

    // デバッグ
    DrawText(TextFormat("SNAKE: %.0f, %.0f", snek.cell.x, snek.cell.y), 10, 10, 20, YELLOW);
    DrawText(TextFormat("FOOD: %.0f, %.0f", food.cell.x, food.cell.y), 200, 10, 20, YELLOW);
    DrawText(TextFormat("MAX CELL: %d, %d", MAX_CELLX, MAX_CELLY), 10, 30, 20, YELLOW);
...
  }
...

食べ物の配置は動作します。
然し、何もしないなら意味がありません。
其の為、蛇が食べ物に衝突し、ポイントをカウントアップし、別の場所にリスポーンさせる必要があります。
成長部分は心配しないで下さい。
次に行います。

又、ランダムの最大値はグリッドサイズの最大値から1を引いた物である事に注意して下さい。/1を引かないと、食べ物が境界外にスポーンする可能性があり、到達出来なく成ります。/従って、食べ物をグリッド内に保ちたいです。

色については、RGB値を0x64から0xFF(詰り100から255)の間に保ち、十分に明るく見える様にします。
システムエンジニアとして、此れはCSSを思い出させるかもしんね?

先ず、食べ物の初期化を関数にします。

  void placeFood() {
    food.cell = {
      static_cast<float>(GetRandomValue(0, MAX_CELLX - 1)),
      static_cast<float>(GetRandomValue(0, MAX_CELLY - 1)),
    };

    Color foodCol;
    foodCol.r = GetRandomValue(100, 255);
    foodCol.g = GetRandomValue(100, 255);
    foodCol.b = GetRandomValue(100, 255);
    foodCol.a = 255;
    food.color = foodCol;
  }

  bool GameMain::Init() {
    snek.cell = { 10, 10 };
    snek.color = RED;
    snek.dir = Right;

    placeFood();

    return true;
  }

次にスコアを追跡しましょう。
ゲームオーバー画面からもアクセス出来る様に、此れをsnake.hhに置きます。

extern int score;

gamemain.ccに戻ります:

...
namespace snake {
  int score;
} // namespace snake

namespace snake::scene {
...
  bool GameMain::Init() {
    score = 0;
...
  }
...

此れにより、メインゲームを開始する時にスコアが常に0に成ります。
そうしないと、プレイヤーが9000ポイントでゲームオーバー画面に到達した場合、次の試行も9000ポイントから始まりますが、赤ちゃん蛇の状態です。
此れは意味がありません。

    // フードを食べたかどうか確認
    if ((static_cast<int>(snek.cell.x) == static_cast<int>(food.cell.x))
     && (static_cast<int>(snek.cell.y) == static_cast<int>(food.cell.y))) {
      score += 100;
      speed += .5f;
      placeFood();
    }

Vector2のXとYの値はデフォルトでfloatなので、整数にキャストする必要があります。
精度ポイント + 連続移動は、グリッドベースのシステムでは上手く合いません。
其の結果、食べ物自体ではなく、食べ物の左上から衝突する事に成ります。
整数にダウンキャストする事で、より正確に成ります。

難易度を上げるには、増加速度を高くし、スコアを低くします。
例:speed += .8f;score += 50;
又は、speed += .2f;score += 200; で簡単にする事を出来ます。
此れは完全に貴方次第です。

スネーク

体を成長させる

約束通り、今度は蛇の体を成長させます。
此れがゲームに欠けていた最後の部分なので、片付けましょう。

...
#include <vector>
...
  struct SnakeSegment {
    Vector2 cell;
  };

  struct Snake {
    std::vector<SnakeSegment> body;
    Color color;
    SnakeDir dir;
    SnakeDir nextDir;
  };

  Vector2 oldPos;
...
  bool GameMain::Init() {
    score = 0;
    speed = 2.5f;

    snek.body.clear();
    snek.body.push_back({ { 10, 10 }, RED });
    snek.body.push_back({ { 9, 10 }, GREEN });
    snek.body.push_back({ { 8, 10 }, BLUE });

    snek.dir = Right;
    snek.nextDir = snek.dir;

    placeFood();

    return true;
  }
...
  bool GameMain::Update() {
    float deltaTime = GetFrameTime() * speed;
    if (deltaTime > 1.f / 5) deltaTime = 1.f / 5;

    if (snek.dir == Left || snek.dir == Right) {
      if (IsKeyPressed(KEY_UP)) snek.nextDir = Up;
      else if (IsKeyPressed(KEY_DOWN)) snek.nextDir = Down;
    } else {
      if (IsKeyPressed(KEY_LEFT)) snek.nextDir = Left;
      else if (IsKeyPressed(KEY_RIGHT)) snek.nextDir = Right;
    }

    if ((snek.nextDir == Left && snek.dir == Right)
     || (snek.nextDir == Right && snek.dir == Left)
     || (snek.nextDir == Up && snek.dir == Down)
     || (snek.nextDir == Down && snek.dir == Up)) {
      snek.nextDir = snek.dir;
    }

    oldPos = snek.body[0].cell;

    if (snek.dir == Left) snek.body[0].cell.x -= 1 * deltaTime;
    else if (snek.dir == Right) snek.body[0].cell.x += 1 * deltaTime;
    else if (snek.dir == Up) snek.body[0].cell.y -= 1 * deltaTime;
    else if (snek.dir == Down) snek.body[0].cell.y += 1 * deltaTime;

    int curGridX = static_cast<int>(snek.body[0].cell.x);
    int curGridY = static_cast<int>(snek.body[0].cell.y);

    if ((curGridX != prevGridX) || (curGridY != prevGridY)) {
      snek.dir = snek.nextDir;
      for (size_t i = snek.body.size() - 1; i > 0; --i)
        snek.body[i].cell = snek.body[i - 1].cell;

      snek.body[1].cell = oldPos;

      prevGridX = curGridX;
      prevGridY = curGridY;
    }

    // フードを食べたかどうか確認
    if ((curGridX == static_cast<int>(food.cell.x))
     && (curGridY == static_cast<int>(food.cell.y))) {
      score += 100;
      speed += .5f;

      SnakeSegment seg = snek.body.back();
      seg.color = food.color;
      snek.body.push_back(seg);
      placeFood();
    }

    // 壁を打ったかどうか確認
    if ((snek.body[0].cell.x < 0 || snek.body[0].cell.x > MAX_CELLX)
     || (snek.body[0].cell.y < 0 || snek.body[0].cell.y > MAX_CELLY)) {
      snake::Close();
      snake::s = snake::GameOver;
      snake::Init();
      return true;
    }

    ClearBackground(BLACK);

    // グリッド
    int cX = static_cast<int>(gridPos.x);
    int cY = static_cast<int>(gridPos.y);
    for (int h = 0; h <= MAX_CELLY; ++h) {
      int y = cY + h * CELL_SIZE;
      DrawLine(cX, y, cX + MAX_CELLX * CELL_SIZE, y, RAYWHITE);
    }
    for (int w = 0; w <= MAX_CELLX; ++w) {
      int x = cX + w * CELL_SIZE;
      DrawLine(x, cY, x, cY + MAX_CELLY * CELL_SIZE, RAYWHITE);
    }

    // プレイヤー
    for (const auto &seg : snek.body) {
      int pX = static_cast<int>(seg.cell.x) * CELL_SIZE;
      int pY = static_cast<int>(seg.cell.y) * CELL_SIZE;
      DrawRectangle(cX + pX, cY + pY, CELL_SIZE, CELL_SIZE, seg.color);
    }

    // フード
    int fX = static_cast<int>(food.cell.x) * CELL_SIZE;
    int fY = static_cast<int>(food.cell.y) * CELL_SIZE;
    DrawRectangle(cX + fX, cY + fY, CELL_SIZE, CELL_SIZE, food.color);

    // スコア
    DrawText(TextFormat("SCORE: %d", score), 10, 10, 20, GREEN);

    return true;
  }
...

デバッグテキストはもう必要ないので削除しました。
蛇のセグメント用の新しい構造体を作成し、cellcolorプロパティを其処に移動しました。
此れにより、蛇の体を追跡出来ます。
最初のセグメントのみを移動させ、他のセグメントは頭に追従させます。
蛇が食べ物を食べると、食べ物と同じ色で成長します。

Vector2 oldPos;をグローバルスコープで宣言した理由があります。
更新ループ内で初期化するとオーバーヘッドが追加され、スローダウンを引き起こし、更に厄介な事に、反応しない入力に成ります。
従って、1度だけ宣言し、更新ループ内で代入する様にします。

スネーク

与えたいアドバイスが1つあります:
解決方法が分からないバグがある場合、次のタスクを完了出来なくする物でない限り、先に進んで後でバグを修正して下さい。
此れは現実の世界でよく起こります。
解決策は後で出てくるかもしんので、同じ問題にいつまでも拘るのではなく、次のタスクに進んでみて下さい。
最終的に何が間違っていたかが分かるでしょう。

今、体の衝突を実装しましょう。

...
  bool selfCollide() {
    const Vector2 &head = snek.body[0].cell;
    int headX = static_cast<int>(head.x);
    int headY = static_cast<int>(head.y);

    for (size_t i = 1; i < snek.body.size(); ++i) {
      int bodyX = static_cast<int>(snek.body[i].cell.x);
      int bodyY = static_cast<int>(snek.body[i].cell.y);

      if (headX == bodyX && headY == bodyY) return true;
    }

    return false;
  }
...
  bool GameMain::Update() {
...
    if ((curGridX != prevGridX) || (curGridY != prevGridY)) {
      snek.dir = snek.nextDir;
      for (size_t i = snek.body.size() - 1; i > 0; --i) snek.body[i].cell = snek.body[i - 1].cell;

      prevGridX = curGridX;
      prevGridY = curGridY;

      if (selfCollide()) {
        snake::Close();
        snake::s = snake::GameOver;
        snake::Init();
        return true;
      }
    }
...
  }

此れで蛇は自分自身に触れると死にます。

ゲームオーバー時のスコア

最後の仕上げ:ゲームオーバー画面にスコアを表示し、ハイスコアも追跡します。
ハイスコアは何処にも保存しないので、ゲームを実行する度にリセットされますが、リトライ間では保持されます。

gameover.ccに:

...
  int highScore = 0;
...
  bool GameOver::Init() {
    if (snake::score > highScore) highScore = snake::score;
    return true;
  }
...
  bool GameOver::Update() {
...
    const char *score = TextFormat("SCORE: %d", snake::score);
    textWidth = MeasureText(score, fontSize);
    DrawText(score, GetScreenWidth() / 2 - textWidth / 2, GetScreenHeight() / 2 + 30, fontSize, RED);

    const char *hScore = TextFormat("HIGH SCORE: %d", highScore);
    textWidth = MeasureText(hScore, fontSize);
    DrawText(hScore, GetScreenWidth() / 2 - textWidth / 2, GetScreenHeight() / 2 + 60, fontSize, RED);
...
  }
...

効果音と音楽

蛇が食べ物を食べる度、蛇が死ぬ度、タイトル画面とゲームオーバー画面でEnterを押した時に効果音を追加出来ます。
ゲームにシンプルな曲を追加する事も出来ます。
此処からダウンロード出来できます:
BGM
確認SFX
死亡SFX
フードSFX

ゲームに組み込める様にする為に、コードに埋め込めるバイトに変換出来ます。
此れにより、単一のバイナリをリリース出来、プレイヤーにファイルの構造を教える必要がありません。

$ xxd -i snake-bgm.mp3 > snake.bgm.h
$ xxd -i snake-confirm.mp3 > snake-confirm.h
$ xxd -i snake-die.mp3 > snake-die.h
$ xxd -i snake-food.mp3 > snake-food.h

snake.hhに:

...
  enum Sfx {
    None = 0,
    Confirm,
    Die,
    Food,

    SFX_COUNT,
  };

  extern Sfx sfx;
...
  void PlaySFX(int sound, float volume);
...

効果音は何処からでも取得する必要があります。
音楽はsnake.cc内でのみロード出来ます。

snake.ccに:

...
#include "../ass/snake-bgm.h"
#include "../ass/snake-confirm.h"
#include "../ass/snake-die.h"
#include "../ass/snake-food.h"

#include <vector>
#include <raylib.h>
#include <raymath.h>
...
  Music bgm = { 0 };
  std::vector<Sound> allSnd;

  void PlayBGM();
  void StopBGM();
  bool IsBGMPlaying();
  void UpdateBGM();

  bool Init() {
    switch (s) {
      case GameMain:
        PlayBGM();
        return gm.Init();
        break;
      case GameOver:
        return go.Init();
        break;
      default: {
        InitAudioDevice();
        SetMasterVolume(.9);
        {
          allSnd.resize(SFX_COUNT);
          Wave w;

          w = LoadWaveFromMemory(".mp3", snake_confirm_mp3, snake_confirm_mp3_len);
          allSnd[Confirm] = LoadSoundFromWave(w);
          UnloadWave(w);

          w = LoadWaveFromMemory(".mp3", snake_die_mp3, snake_die_mp3_len);
          allSnd[Die] = LoadSoundFromWave(w);
          UnloadWave(w);

          w = LoadWaveFromMemory(".mp3", snake_food_mp3, snake_food_mp3_len);
          allSnd[Food] = LoadSoundFromWave(w);
          UnloadWave(w);
        }
        return ts.Init();
      }
      break;
    }
  }

  bool Update() {
    switch (s) {
      case GameMain:
        UpdateBGM();
        return gm.Update();
        break;
      case GameOver:
        return go.Update();
        break;
      default:
        return ts.Update();
        break;
    }
  }

  void Close() {
    switch (s) {
      case GameMain:
        StopBGM();
        gm.Close();
        break;
      case GameOver:
        go.Close();
        break;
      default:
        ts.Close();
        break;
    }
  }

  void PlaySFX(int sound, float volume) {
    if (sound <= None || sound >= SFX_COUNT) return;
    volume = Clamp(volume, 0.f, 1.f);
    SetSoundVolume(allSnd[sound], volume);
    PlaySound(allSnd[sound]);
  }

  void PlayBGM() {
    if (IsBGMPlaying()) return;
    bgm = LoadMusicStreamFromMemory(".mp3", snake_bgm_mp3, snake_bgm_mp3_len);
    bgm.looping = true;
    SetMusicVolume(bgm, 1.f);
    PlayMusicStream(bgm);
  }

  void StopBGM() {
    if (!IsBGMPlaying()) return;
    StopMusicStream(bgm);
    UnloadMusicStream(bgm);
  }

  bool IsBGMPlaying() {
    return IsMusicStreamPlaying(bgm);
  }

  void UpdateBGM() {
    if (!IsBGMPlaying()) return;
    UpdateMusicStream(bgm);
  }

title.cc:

    if (IsKeyPressed(KEY_ENTER)) {
      snake::PlaySFX(snake::Confirm, 1.f);
      snake::Close();
      snake::s = snake::GameMain;
      snake::Init();
    }

gameover.cc:

    if (IsKeyPressed(KEY_ENTER)) {
      snake::PlaySFX(snake::Confirm, 1.f);
      snake::Close();
      snake::s = snake::GameMain;
      snake::Init();
    }

gamemain.cc:

...
      if (selfCollide()) {
        snake::PlaySFX(snake::Die, 1.f);
        snake::Close();
        snake::s = snake::GameOver;
        snake::Init();
        return true;
      }
...
    // フードを食べたかどうか確認
    if ((curGridX == static_cast<int>(food.cell.x))
     && (curGridY == static_cast<int>(food.cell.y))) {
      score += 100;
      speed += .5f;

      SnakeSegment seg = snek.body.back();
      seg.color = food.color;
      snek.body.push_back(seg);
      snake::PlaySFX(snake::Food, 1.f);
      placeFood();
    }
...
    // 壁を打ったかどうか確認
    if ((snek.body[0].cell.x < 0 || snek.body[0].cell.x > MAX_CELLX)
     || (snek.body[0].cell.y < 0 || snek.body[0].cell.y > MAX_CELLY)) {
      snake::PlaySFX(snake::Die, 1.f);
      snake::Close();
      snake::s = snake::GameOver;
      snake::Init();
      return true;
    }
...

タイトル画面

今、あたしがしたいのは見た目の良いタイトル画面を作る事だけです。
一貫性を保つ為に、8ビットスタイルにしたいです。

Asepriteで作成しました。
画像
ソース

$ xxd -i snake-title.png > snake-title.h

title.ccに:

#include <snake/scene/title.hh>
#include <snake/snake.hh>
#include <raylib.h>

#include "../ass/snake-title.h"

namespace snake::scene {
  static Texture2D bg{};

  bool TitleScreen::Init() {
    Image img = LoadImageFromMemory(".png", snake_title_png, snake_title_png_len);
    bg = LoadTextureFromImage(img);
    UnloadImage(img);

    return true;
  }

  bool TitleScreen::Update() {
    if (IsKeyPressed(KEY_ENTER)) {
      snake::PlaySFX(snake::Confirm, 1.f);
      snake::Close();
      snake::s = snake::GameMain;
      snake::Init();
    }

    ClearBackground(BLACK);
    DrawTexture(bg, 0, 0, WHITE);

    return true;
  }

  void TitleScreen::Close() {
    UnloadTexture(bg);
  }
} // namespace snake::scene

結論

此れで完全なゲームが完成しました!
今、完全なプレイ可能なゲームも此処でリリースします:
ダウンロード
此れで初めてのゲームが完成したので、2番目のゲームはもっと難しい物にしましょう。

以上