現在のブログ
ゲーム開発ブログ (2025年~) Gamedev Blog (2025~) 游戏开发博客 (2025年~)
レガシーブログ
テクノロジーブログ (2018~2024年) リリースノート (2023~2025年) MeatBSD (2024年)
【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的语法非常易懂。
简单解释的话:
- 设置屏幕宽度和高度变量。
- 仅在Release构建中禁用跟踪日志。
- 使窗口可调整大小。
- 创建新窗口。
- 确保窗口不会关闭。
- 将帧率设置为120 FPS。
- 创建一个boolean来检查游戏是否仍在运行。
- 创建游戏循环,在其中按Q键将
running设置为false并退出循环。 - 在窗口中绘制洋红色。
- 结束时关闭窗口。
默认情况下,按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 + /。
场景管理
接下来,需要建立所需的场景。
- 标题画面
- 主游戏玩法
- 游戏结束
在游戏结束画面中,为玩家提供重新开始游戏或退出游戏的选项。
没有真正必要提供返回标题画面的选项。
因为标题画面所做的就是给玩家准备游戏的时间,并以好的方式欢迎他们。
游戏结束画面已经履行了这个功能,只是欢迎方式不太好。
在include和src中创建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;
}
...
调试文本不再需要所以删除了。
创建了蛇段的新结构体,将cell和color属性移到那里。
这样可以跟踪蛇的身体。
只移动第一个段,其他段跟随头部。
蛇吃食物时,用与食物相同的颜色成长。
在全局作用域声明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位风格。
$ 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
结论
这样就完成了一个完整的游戏!
现在也在此处发布完整的可玩游戏:
下载
既然第一个游戏完成了,下一个游戏就做更难的吧。
完