2026-06-22 00:26:42
谏访子
c++
raylib
gamedev
easy

【C++】初次游戏制作指南

经验丰富的“系统工程师”,想要人生中第一次制作游戏,但不知道从哪里开始。
算了,放弃吧。
游戏什么的你做不出来的。
开玩笑的!
尽可能通俗易懂地介绍第一次游戏的制作方法。

“你是不是在想,这是在讨论其他人都无法理解的非常高深的课题?”
不用担心。
这次尽量面向初学者!
不过,只有一个请求。
请仔细阅读这篇文章,并在文本编辑器或IDE中自己逐行重写所有代码。
这才是真正学习制作游戏的唯一方法。

游戏

决定制作一个简单的贪吃蛇游戏。
贪吃蛇是非常好的初学者游戏。
制作难度不高,同时能教会后续更复杂游戏所需的所有基础知识。
游戏循环、碰撞检测、随机性、背景渲染、场景管理、分数系统、字体、实时用户输入等,贪吃蛇包含了许多元素,这些元素存在于地球上的所有游戏中。

完成的项目可以在这里查看。

依赖关系

需要以下工具:

  • 支持C++20及更高版本的C++编译器(截至2026/06/22,C++20已被大多数编译器支持
  • CMake
  • 你喜欢的编辑器(我使用Visual Studio 2022)
  • Raylib
  • Git

注:由于日文版页面显示旧信息,因此使用了英文版。

撰写时,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

创建一个名为“Snake”的空目录。
在其中创建以下目录:

  • src
  • include
  • deps

src目录放置除main.cc之外的所有源代码。
main.cc放在项目根目录。
include目录放置所有头文件。
这样可以在代码中创建干净的接口。
deps目录放置Raylib。

解压Raylib的ZIP或Tarball,将raylib-6.0重命名为raylib,并删除以下文件和目录:

$ 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时,也不是每次都重新编译全部,而是只编译修改过的代码,这一点很优秀。
这大大加快了大型项目的迭代速度。

幸运的是,只需要在项目开始时设置一次,如果设置正确,以后就可以忘记了。

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上在Release构建中移除终端窗口,其他所有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. 仅在Release构建中禁用跟踪日志。
  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页面写差评,生气进度没保存。
因此,在这种情况下,把内存管理交给操作系统吧。

另一个提示是不要抛出异常。
在游戏机上这是完全不可能的,但在PC上会引起瓶颈,因此许多游戏工作室完全禁止或严格限制它。
抛出异常对系统编程非常方便,但不适合游戏编程。
推荐使用断言。

另外,如果可能,将堆分配最小化到真正需要的。
内存分配很慢,在栈上执行要快得多。
不过在C++中完全不分配内存是不可能的。
标准库在后台做这些。
C中可能。

无论如何都要避免多态。
不仅引起瓶颈,还会无故使代码复杂化。

最后,尽量避免在头文件中放置include。
会减慢编译时间,并可能导致头文件相互循环引起编译器错误。
如果两个文件需要相互依赖,请改用前向声明。

另外,如果使用Visual Studio,试试按CTRL + K然后CTRL + O。
这可以在头文件和源文件之间切换,非常节省时间。
生成多个光标可以使用Shift + Alt + Up/Down。
注释行使用CTRL + /。

场景管理

接下来,需要建立所需的场景。

  • 标题画面
  • 主游戏玩法
  • 游戏结束

在游戏结束画面中,为玩家提供重新开始游戏或退出游戏的选项。
没有真正必要提供返回标题画面的选项。
因为标题画面所做的就是给玩家准备游戏的时间,并以好的方式欢迎他们。
游戏结束画面已经履行了这个功能,只是欢迎方式不太好。

includesrc中创建scene目录。
在其中创建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值,并为三个函数添加Copyright的case即可调整,其他什么都不需要改。

用这种方法可以在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是为了保证正确关闭旧场景并只打开新场景一次。
但这是一个bug。
因为按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了,双赢。

游戏循环

标题画面保持原样。
本文最后处理。
游戏开发中,总是先专注于主游戏循环。
如果标题画面需要功能,可以放入基本占位符文本,但一开始不要花太多时间。
在我们的案例中,唯一功能是移动到主游戏循环,所以不用担心。

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;
...
  }

原则上,这段代码能工作。
但有一个问题:根据硬件不同,蛇可能太快或太慢。
在我的PC上像子弹一样快。

为了解决这个问题,我想介绍delta time。
什么是delta time?
没听过!

开玩笑的。
基本上,delta time保证在各种硬件上动作同样流畅。
但这并不意味着必须在所有地方使用delta time。
只应该用于自动移动。
用户控制的移动应该不使用delta time。
否则玩家会体验输入延迟,这不好。

    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;

这样就能控制蛇了。
我觉得移动太慢。
要修正,可以将speed乘以deltaTime

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

float speed = 2.5f;移到函数外面。
Init方法中初始化speed为2.5f。
这样随着游戏进行可以逐渐加快蛇的速度。

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

还有另一个问题。
开始游戏后,尽快快速按上和左,蛇不会先向上而是向左转。
这是问题。
要修正,需要添加额外检查:

    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;
    }

这样蛇就会先向上再向左。
看起来像是同时向上和向左,但比蛇吃掉自己好。

你可能注意到了,我先计算所有事件,然后再绘制到屏幕上。
这是推荐的方法。
总是先处理逻辑,再把结果绘制到屏幕上比较好。
而不是反过来。
在贪吃蛇这样的简单游戏中问题不大,但在更复杂的项目中,先绘制再计算或边绘制边计算会导致延迟。
不是硬件思考意义上的延迟,而是玩家注意到输入几秒后动作才发生的延迟。
在游戏中,需要让动作看起来尽可能与输入同步。

另一个需要知道的是,即使在计算和计算之间,顺序也很重要。
例如,与移动相关的碰撞需要在移动之后而不是之前检查。
提前做的话,会看到蛇先移出边界,然后游戏结束。
我们想要相反的顺序。

游戏结束

接下来实现游戏结束。
当蛇撞到墙时显示游戏结束画面。

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;是有原因的。
在更新循环内初始化会增加开销,导致减速,更麻烦的是输入无响应。
因此只声明一次,在更新循环内赋值。

蛇

给你一个建议:
如果有不知道解决方法的bug,只要它不妨碍完成下一个任务,就先继续,以后修复bug。
这在现实世界中经常发生。
解决方案可能以后会出现,所以不要一直纠结同一个问题,试着进入下一个任务。
最终你会明白哪里错了。

现在实现身体碰撞。

...
  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

结论

这样就完成了一个完整的游戏!
现在也在此处发布完整的可玩游戏:
下载
既然第一个游戏完成了,下一个游戏就做更难的吧。