用SDL3完成一个扫雷

第一章:开发环境搭建

1.1 为什么选这些工具?

工具 用途 为什么
MSVC (Visual Studio BuildTools) C++ 编译器 Windows 原生编译器,SDK 兼容性最好
CMake 构建系统 SDL3 官方推荐,跨平台
VSCode 编辑器 轻量快速,插件丰富,免费
Ninja 构建执行器 比 MSBuild 快,支持并行编译

1.2 安装 Visual Studio BuildTools

SDK 和编译器来自 Visual Studio 2022 的 BuildTools 组件。

步骤

  1. 访问 https://visualstudio.microsoft.com/downloads/
  2. 找到 "Visual Studio 2022 生成工具"(BuildTools),下载安装
  3. 在 Workloads 页面勾选:"使用 C++ 的桌面开发"
  4. 在单个组件中确认包含:
    • MSVC v143 工具集(或更新版本)
    • Windows 11 SDK(或 Windows 10 SDK)
    • CMake C++ 工具
  5. 点击安装,等待完成(约 6-8 GB)

验证安装

打开 开始菜单 → Visual Studio 2022 → Developer Command Prompt for VS 2022,运行:

powershell 复制代码
cl.exe
# 应输出:Microsoft (R) C/C++ Optimizing Compiler Version 19.xx

如果找不到 Developer Command Prompt,说明 BuildTools 安装不完整。回到安装器勾选 "C++ CMake 工具"。

1.3 安装 CMake

虽说 VS 自带 CMake,但我们单独安装最新版以获得更好支持。

  1. 访问 https://cmake.org/download/
  2. 下载 Windows x64 Installer(.msi 文件)
  3. 运行安装,勾选 "Add CMake to system PATH"
  4. 安装完成

验证

powershell 复制代码
cmake --version
# cmake version 4.x.x

1.4 安装 Ninja

Ninja 是一个极快的构建工具,专门为增量编译优化。SDL3 官方示例推荐使用它。

powershell 复制代码
# 方法 1:用 winget(Windows 11 自带)
winget install Ninja-build.Ninja

# 方法 2:手动下载
# 从 https://github.com/ninja-build/ninja/releases 下载 ninja-win.zip
# 解压出 ninja.exe 放到某个 PATH 目录(如 C:\Windows\System32)

验证

powershell 复制代码
ninja --version
# 1.xx.x

1.5 安装 VSCode

  1. 访问 https://code.visualstudio.com/
  2. 下载 Windows 安装版
  3. 安装时勾选 "将 Code 添加到 PATH"

安装关键插件

打开 VSCode,按 Ctrl+Shift+X 打开扩展面板,安装:

插件 用途
C/C++ (Microsoft) C++ 语法高亮、IntelliSense
CMake Tools (Microsoft) CMake 项目支持、配置/构建快捷按钮
clangd (LLVM) 更好的代码补全和诊断
CMake (twxs) CMakeLists.txt 语法支持

配置 clangd(可选但推荐)

在项目根目录创建 .clangd 文件:

yaml 复制代码
CompileFlags:
  CompilationDatabase: build/default

这告诉 clangd 从 CMake 生成的 compile_commands.json 读取编译参数。

1.6 创建项目目录

powershell 复制代码
mkdir minesweeper-tutorial
cd minesweeper-tutorial
code .

后面的所有代码都在这个目录中编写。


第二章:第一个 SDL3 窗口

2.1 SDL3 简介

SDL (Simple DirectMedia Layer) 是一个跨平台的多媒体库,提供:

  • 窗口创建和管理
  • 2D/3D 渲染
  • 输入处理(键盘、鼠标、手柄)
  • 音频播放

SDL3 于 2025 年发布,相比 SDL2 有大幅 API 改进。我们用 SDL3 + SDL_ttf(文字渲染库)。

2.2 获取 SDL3 源码

我们把 SDL3 的源码直接放进项目(这叫 vendoring,"供应商化"),好处是:

  • 不需要系统级安装
  • 版本锁定,不会因系统升级而变
  • 可以调试 SDL3 内部代码
powershell 复制代码
git clone https://github.com/libsdl-org/SDL --depth 1
git clone https://github.com/libsdl-org/SDL_ttf --depth 1

如果你不想安装 git,也可以从 GitHub 手动下载 ZIP 并解压到项目目录中。

项目目录现在长这样:

复制代码
minesweeper-tutorial/
├── SDL/          # SDL3 源码
├── SDL_ttf/      # SDL_ttf 源码
└── .clangd       # clangd 配置

2.3 创建 CMakeLists.txt

在项目根目录创建 CMakeLists.txt

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

# 设置输出目录,确保 DLL 和 EXE 在一起
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$<CONFIGURATION>")

# 声明项目
project(minesweeper)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# MSVC 特定设置:UTF-8 源文件支持和并行编译
if(MSVC)
    add_compile_options(/utf-8)
    if(NOT CMAKE_GENERATOR STREQUAL "Ninja")
        add_definitions(/MP)
    endif()
endif()

# 禁用共享库构建(在需要静态链接的平台上)
if((APPLE AND NOT CMAKE_SYSTEM_NAME MATCHES "Darwin") OR EMSCRIPTEN)
    set(BUILD_SHARED_LIBS OFF CACHE INTERNAL "")
    set(SDL_SHARED OFF)
else()
    set(SDL_SHARED ON)
endif()

# 可执行文件
add_executable(${PROJECT_NAME})

# 源文件
target_sources(${PROJECT_NAME} PRIVATE
    src/main.cpp
)

target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23)

# 添加 SDL3 子目录
add_subdirectory(SDL EXCLUDE_FROM_ALL)

# 添加 SDL_ttf(文字渲染)
set(SDLTTF_VENDORED ON)
add_subdirectory(SDL_ttf EXCLUDE_FROM_ALL)

# 链接库
target_link_libraries(${PROJECT_NAME} PUBLIC
    SDL3_ttf::SDL3_ttf
    SDL3::SDL3              # SDL 必须放在最后
)

# 在 Visual Studio 中设为启动项目
set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY
    VS_STARTUP_PROJECT "${PROJECT_NAME}")

CMakeLists.txt 语法说明

  • project() 声明项目名
  • add_executable() 创建可执行文件
  • target_sources() 添加源文件
  • target_link_libraries() 链接库
  • add_subdirectory() 引入子项目(SDL3 和 SDL_ttf)

2.4 创建 CMakePresets.json

这个文件让我们用 cmake --preset default 一键配置,不用记长命令:

json 复制代码
{
    "version": 8,
    "configurePresets": [
        {
            "name": "default",
            "displayName": "Ninja (MSVC) - Debug",
            "description": "使用 Ninja 生成器 + MSVC,Debug 模式",
            "generator": "Ninja",
            "binaryDir": "${sourceDir}/build/${presetName}",
            "cacheVariables": {
                "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
                "CMAKE_BUILD_TYPE": "Debug"
            }
        }
    ],
    "buildPresets": [
        {
            "name": "debug",
            "configurePreset": "default",
            "displayName": "构建 (Debug)",
            "configuration": "Debug"
        }
    ]
}

2.5 第一个源文件

创建 src/main.cpp

cpp 复制代码
/**
 * @file main.cpp
 * @brief SDL3 扫雷游戏 ------ 教学示例
 */

// SDL_MAIN_USE_CALLBACKS 让 SDL3 使用回调式主循环
// 好处:不需要自己写 main() 和 while 循环
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>

#include <print>

// ── 应用初始化 ─────────────────────────────────────────────
SDL_AppResult SDL_AppInit(void** appstate, int argc, char* argv[])
{
    // 初始化 SDL:只需要视频子系统
    if (!SDL_Init(SDL_INIT_VIDEO)) {
        std::println(stderr, "SDL 初始化失败:{}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    // 创建窗口
    SDL_Window* window = SDL_CreateWindow(
        "SDL3 扫雷教程",
        400, 300,
        SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY
    );
    if (!window) {
        std::println(stderr, "窗口创建失败:{}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    // 创建渲染器
    SDL_Renderer* renderer = SDL_CreateRenderer(window, nullptr);
    if (!renderer) {
        std::println(stderr, "渲染器创建失败:{}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    // 显示窗口
    SDL_ShowWindow(window);

    std::println("SDL3 应用已启动!");
    return SDL_APP_CONTINUE;
}

// ── 事件处理 ───────────────────────────────────────────────
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
    // 点击关闭按钮时退出
    if (event->type == SDL_EVENT_QUIT) {
        return SDL_APP_SUCCESS;
    }
    return SDL_APP_CONTINUE;
}

// ── 每帧渲染 ──────────────────────────────────────────────
SDL_AppResult SDL_AppIterate(void* appstate)
{
    // 此时我们还没法访问 renderer,所以先不做任何事
    return SDL_APP_CONTINUE;
}

// ── 退出清理 ──────────────────────────────────────────────
void SDL_AppQuit(void* appstate, SDL_AppResult result)
{
    SDL_Quit();
    std::println("SDL3 应用已退出。");
}

关键概念SDL_MAIN_USE_CALLBACKS 是 SDL3 的重大改进------你不再需要手写 main()while 循环。SDL 自动管理事件循环,你只需实现四个回调函数。这比 SDL2 更简洁、更不容易出错。

2.6 构建与运行

在 VSCode 终端(Ctrl+`` )中:

powershell 复制代码
# 第一步:配置项目(仅需一次)
cmake --preset default

# 第二步:编译
cmake --build build/default

# 第三步:运行
.\build\default\Debug\minesweeper.exe

你应该看到:一个 400×300 的空白窗口弹出来,然后可以手动关闭。

遇到问题?

  • cmake: command not found → CMake 未安装或未加入 PATH
  • Ninja not found → 安装 Ninja 或改用 MSBuild 生成器
  • SDL.h not found → 确认 SDL 和 SDL_ttf 目录在项目根目录中

第三章:SDL3 回调式主循环详解

3.1 四个回调的职责

SDL3 的 SDL_MAIN_USE_CALLBACKS 模式下,SDL 接管了 main() 函数,改为调用你的四个回调:
你的回调 SDL3 内部 你的回调 SDL3 内部 #mermaid-svg-ErjYuKueBueGMEAU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ErjYuKueBueGMEAU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ErjYuKueBueGMEAU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ErjYuKueBueGMEAU .error-icon{fill:#552222;}#mermaid-svg-ErjYuKueBueGMEAU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ErjYuKueBueGMEAU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ErjYuKueBueGMEAU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ErjYuKueBueGMEAU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ErjYuKueBueGMEAU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ErjYuKueBueGMEAU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ErjYuKueBueGMEAU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ErjYuKueBueGMEAU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ErjYuKueBueGMEAU .marker.cross{stroke:#333333;}#mermaid-svg-ErjYuKueBueGMEAU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ErjYuKueBueGMEAU p{margin:0;}#mermaid-svg-ErjYuKueBueGMEAU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ErjYuKueBueGMEAU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ErjYuKueBueGMEAU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ErjYuKueBueGMEAU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU .sequenceNumber{fill:white;}#mermaid-svg-ErjYuKueBueGMEAU #sequencenumber{fill:#333;}#mermaid-svg-ErjYuKueBueGMEAU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU .messageText{fill:#333;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ErjYuKueBueGMEAU .labelText,#mermaid-svg-ErjYuKueBueGMEAU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .loopText,#mermaid-svg-ErjYuKueBueGMEAU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ErjYuKueBueGMEAU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ErjYuKueBueGMEAU .noteText,#mermaid-svg-ErjYuKueBueGMEAU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ErjYuKueBueGMEAU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ErjYuKueBueGMEAU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ErjYuKueBueGMEAU .actorPopupMenu{position:absolute;}#mermaid-svg-ErjYuKueBueGMEAU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ErjYuKueBueGMEAU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ErjYuKueBueGMEAU .actor-man circle,#mermaid-svg-ErjYuKueBueGMEAU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ErjYuKueBueGMEAU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每一帧 清理资源 SDL_AppInit() SDL_APP_CONTINUE SDL_AppEvent(event) SDL_APP_CONTINUE SDL_AppIterate() SDL_APP_CONTINUE SDL_AppQuit(result)

回调 何时调用 职责
SDL_AppInit 应用启动时,一次 初始化 SDL、创建窗口/渲染器、加载资源
SDL_AppEvent 有事件发生时,多次 处理键盘、鼠标、窗口关闭等事件
SDL_AppIterate 每帧,约 60 FPS 更新游戏状态、绘制画面
SDL_AppQuit 应用退出时,一次 释放所有资源

3.2 返回值

返回值 含义
SDL_APP_CONTINUE 继续运行
SDL_APP_SUCCESS 正常退出
SDL_APP_FAILURE 异常退出(错误发生)

重点SDL_APP_SUCCESSSDL_APP_FAILURE 都会导致 SDL 退出。区别在于用于日志记录。

3.3 用 appstate 在回调间共享数据

四个回调是独立的函数,如何共享数据?答案是 void* appstate

SDL_AppInit 中分配内存,把指针存到 *appstate;其他回调通过 appstate 读取这个指针。

示例:让窗口显示红色背景

修改 src/main.cpp

cpp 复制代码
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <print>

// 应用状态 ------ 在回调之间共享
struct AppState {
    SDL_Window*   window   = nullptr;
    SDL_Renderer* renderer = nullptr;
    bool          quit     = false;
};

// ── SDL_AppInit ──────────────────────────────────────────
SDL_AppResult SDL_AppInit(void** appstate, int, char*[])
{
    if (!SDL_Init(SDL_INIT_VIDEO)) {
        std::println(stderr, "Error: {}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    // 创建窗口
    auto* window = SDL_CreateWindow("SDL3 回调教程", 400, 300,
        SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
    if (!window) return SDL_APP_FAILURE;

    // 创建渲染器
    auto* renderer = SDL_CreateRenderer(window, nullptr);
    if (!renderer) return SDL_APP_FAILURE;

    // 设置 VSync
    SDL_SetRenderVSync(renderer, 1);

    // ★ 关键:把状态存到 appstate
    *appstate = new AppState{ window, renderer };

    SDL_ShowWindow(window);
    std::println("应用已启动。按 ESC 或关闭按钮退出。");
    return SDL_APP_CONTINUE;
}

// ── SDL_AppEvent ─────────────────────────────────────────
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
    auto& state = *static_cast<AppState*>(appstate);

    switch (event->type) {
    case SDL_EVENT_QUIT:
        return SDL_APP_SUCCESS;

    case SDL_EVENT_KEY_DOWN:
        if (event->key.key == SDLK_ESCAPE)
            return SDL_APP_SUCCESS;
        break;
    }

    return SDL_APP_CONTINUE;
}

// ── SDL_AppIterate ───────────────────────────────────────
SDL_AppResult SDL_AppIterate(void* appstate)
{
    auto& state = *static_cast<AppState*>(appstate);

    // 用红色清屏
    SDL_SetRenderDrawColor(state.renderer, 200, 50, 50, 255);
    SDL_RenderClear(state.renderer);

    // 呈现到屏幕
    SDL_RenderPresent(state.renderer);

    return SDL_APP_CONTINUE;
}

// ── SDL_AppQuit ──────────────────────────────────────────
void SDL_AppQuit(void* appstate, SDL_AppResult)
{
    auto* state = static_cast<AppState*>(appstate);
    if (state) {
        if (state->renderer) SDL_DestroyRenderer(state->renderer);
        if (state->window)   SDL_DestroyWindow(state->window);
        delete state;
    }
    SDL_Quit();
    std::println("应用已退出。");
}

编译运行,你应该看到一个红色窗口。按 ESC 键或关闭按钮退出。

回调用法小结

复制代码
SDL_AppInit   → new + *appstate = ...   (创建状态)
SDL_AppEvent  → static_cast<AppState*>  (读取状态,处理事件)
SDL_AppIterate → static_cast<AppState*> (读取状态,更新渲染)
SDL_AppQuit   → static_cast<AppState*> (清理,delete)

这个模式贯穿整个扫雷项目。


第四章:用 SDL_ttf 渲染文字

4.1 什么是 SDL_ttf?

SDL 本身没有文字渲染能力。SDL_ttf 是 SDL 的文字渲染库,使用 FreeType 将 TTF 字体光栅化为像素图,再转为 SDL 纹理。

我们已经把 SDL_ttf/ 放在项目根目录了,CMakeLists.txt 也已链接它。

4.2 获取字体文件

你需要一个 .ttf 字体文件。推荐 Inter(开源可变字体),下载 Inter-VariableFont.ttf 放到 src/ 目录。

任何 .ttf 字体都可以,把名字改成你自己的字体文件名即可。

4.3 在屏幕上显示 "Hello SDL!"

修改 src/main.cpp

cpp 复制代码
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <print>
#include <string>

struct AppState {
    SDL_Window*   window   = nullptr;
    SDL_Renderer* renderer = nullptr;
    TTF_Font*     font     = nullptr;  // ★ TTF 字体
};

SDL_AppResult SDL_AppInit(void** appstate, int, char*[])
{
    // 1. 初始化 SDL
    if (!SDL_Init(SDL_INIT_VIDEO)) {
        std::println(stderr, "Error: {}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    // 2. 初始化 SDL_ttf
    if (!TTF_Init()) {
        std::println(stderr, "TTF Error: {}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    // 3. 窗口 + 渲染器
    auto* window = SDL_CreateWindow("SDL3 文字渲染", 500, 200,
        SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
    auto* renderer = SDL_CreateRenderer(window, nullptr);
    if (!window || !renderer) return SDL_APP_FAILURE;
    SDL_SetRenderVSync(renderer, 1);

    // 4. 获取字体路径
    auto* base_path = SDL_GetBasePath();
    std::string font_path = std::string(base_path) + "Inter-VariableFont.ttf";
    SDL_free(base_path);

    // 5. 加载字体(字号 36)
    TTF_Font* font = TTF_OpenFont(font_path.c_str(), 36);
    if (!font) {
        std::println(stderr, "字体加载失败:{}", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    *appstate = new AppState{ window, renderer, font };
    SDL_ShowWindow(window);
    std::println("文字渲染示例已启动!");
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
    if (event->type == SDL_EVENT_QUIT) return SDL_APP_SUCCESS;
    if (event->type == SDL_EVENT_KEY_DOWN &&
        event->key.key == SDLK_ESCAPE)
        return SDL_APP_SUCCESS;
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void* appstate)
{
    auto& s = *static_cast<AppState*>(appstate);

    // 灰色背景
    SDL_SetRenderDrawColor(s.renderer, 60, 60, 60, 255);
    SDL_RenderClear(s.renderer);

    // ★ SDL_ttf 文字渲染流程:
    //   1. TTF_RenderText_Solid → Surface(CPU 像素)
    //   2. SDL_CreateTextureFromSurface → Texture(GPU 纹理)
    //   3. SDL_RenderTexture → 画到屏幕上
    //   4. 清理临时 Surface 和 Texture

    const char* text = "Hello SDL3!";
    SDL_Color    fg  = {255, 255, 255, 255}; // 白色

    SDL_Surface* surface = TTF_RenderText_Solid(
        s.font, text, strlen(text), fg);
    SDL_Texture* texture = SDL_CreateTextureFromSurface(
        s.renderer, surface);

    // 获取纹理尺寸
    auto props = SDL_GetTextureProperties(texture);
    float w = SDL_GetNumberProperty(props,
        SDL_PROP_TEXTURE_WIDTH_NUMBER, 0);
    float h = SDL_GetNumberProperty(props,
        SDL_PROP_TEXTURE_HEIGHT_NUMBER, 0);

    // 居中显示
    SDL_FRect dst{ 250 - w/2, 100 - h/2, w, h };
    SDL_RenderTexture(s.renderer, texture, nullptr, &dst);

    // 清理临时资源(重要!否则内存泄漏)
    SDL_DestroyTexture(texture);
    SDL_DestroySurface(surface);

    SDL_RenderPresent(s.renderer);
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void* appstate, SDL_AppResult)
{
    auto* s = static_cast<AppState*>(appstate);
    if (s) {
        if (s->font)     TTF_CloseFont(s->font);
        if (s->renderer) SDL_DestroyRenderer(s->renderer);
        if (s->window)   SDL_DestroyWindow(s->window);
        delete s;
    }
    TTF_Quit();
    SDL_Quit();
}

编译运行,看到灰色背景上显示白色 "Hello SDL3!"。

4.4 TTF 渲染模式对比

SDL3_ttf 支持三种渲染模式:

函数 质量 性能 适用场景
TTF_RenderText_Solid 粗糙(单色) 最快 小字、数字、UI 标签
TTF_RenderText_Blended 平滑(抗锯齿) 中等 标题、大号文字、emoji
TTF_RenderText_LCD 清晰(LCD 优化) 最慢 阅读性文字

4.5 复制字体到构建目录

编译后字体文件不会自动复制到 exe 旁边。在 CMakeLists.txt 末尾添加:

cmake 复制代码
# 复制字体到构建目录(Windows/Linux)
macro(copy_helper filename)
    add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
            "${CMAKE_CURRENT_LIST_DIR}/src/${filename}"
            "${CMAKE_BINARY_DIR}/$<CONFIGURATION>/${filename}"
    )
endmacro()
copy_helper("Inter-VariableFont.ttf")

copy_if_different 只在文件内容变化时才复制,避免每次构建都触发重新链接。


第五章:SDL 2D 图元绘制

5.1 扫雷需要什么?

扫雷的棋盘由格子组成------我们需要画矩形(填充 + 边框)。SDL 的 2D 渲染 API 提供了:

  • SDL_SetRenderDrawColor --- 设置画笔颜色
  • SDL_RenderClear --- 用当前颜色填充整个画面
  • SDL_RenderFillRect --- 填充一个矩形
  • SDL_RenderRect --- 画出矩形边框
  • SDL_RenderTexture --- 画纹理(上一章用过了)

5.2 画一个 3D 凸起按钮

MS-DOS/Windows 时代扫雷的格子是经典的 3D 凸起效果:

复制代码
   ╔════════╗
   ║        ║  ← 亮边(左上)
   ║        ║  ← 暗边(右下)
   ╚════════╝

实现方法:用 SDL_RenderFillRect 分别在主体、上下左右边框用不同颜色填充。

创建 src/ch5_demo.cpp(可独立编译测试)

cpp 复制代码
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <print>

struct State { SDL_Window* w; SDL_Renderer* r; };

// 颜色常量
constexpr SDL_Color LIGHT{255, 255, 255, 255};  // 亮色(凸起的左/上边)
constexpr SDL_Color MID  {192, 192, 192, 255};  // 中间色(主体)
constexpr SDL_Color DARK {128, 128, 128, 255};  // 暗色(凸起的右/下边)

void draw_raised_rect(SDL_Renderer* r, SDL_FRect rect)
{
    // 主体填充
    SDL_SetRenderDrawColor(r, MID.r, MID.g, MID.b, 255);
    SDL_RenderFillRect(r, &rect);

    // 上边框(亮色,2px 宽)
    SDL_SetRenderDrawColor(r, LIGHT.r, LIGHT.g, LIGHT.b, 255);
    SDL_FRect top{rect.x, rect.y, rect.w, 2};
    SDL_RenderFillRect(r, &top);

    // 左边框(亮色,2px 宽)
    SDL_FRect left{rect.x, rect.y, 2, rect.h};
    SDL_RenderFillRect(r, &left);

    // 下边框(暗色,2px 宽)
    SDL_SetRenderDrawColor(r, DARK.r, DARK.g, DARK.b, 255);
    SDL_FRect bottom{rect.x, rect.y + rect.h - 2, rect.w, 2};
    SDL_RenderFillRect(r, &bottom);

    // 右边框(暗色,2px 宽)
    SDL_FRect right{rect.x + rect.w - 2, rect.y, 2, rect.h};
    SDL_RenderFillRect(r, &right);
}

SDL_AppResult SDL_AppInit(void** s, int, char*[])
{
    SDL_Init(SDL_INIT_VIDEO);
    auto* w = SDL_CreateWindow("3D 按钮", 400, 300, 0);
    auto* r = SDL_CreateRenderer(w, nullptr);
    *s = new State{w, r};
    SDL_ShowWindow(w);
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(void*, SDL_Event* e)
{
    if (e->type == SDL_EVENT_QUIT) return SDL_APP_SUCCESS;
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void* s)
{
    auto& st = *static_cast<State*>(s);

    // 背景
    SDL_SetRenderDrawColor(st.r, 180, 180, 180, 255);
    SDL_RenderClear(st.r);

    // 画一个凸起按钮
    SDL_FRect btn{100, 100, 200, 80};
    draw_raised_rect(st.r, btn);

    // 画一个凹陷按钮(按下状态:亮暗色互换)
    // 交换亮暗色即可实现
    SDL_FRect btn2{100, 210, 200, 50};
    // 主体
    SDL_SetRenderDrawColor(st.r, MID.r, MID.g, MID.b, 255);
    SDL_RenderFillRect(st.r, &btn2);
    // 上/左边框用暗色
    SDL_SetRenderDrawColor(st.r, DARK.r, DARK.g, DARK.b, 255);
    {SDL_FRect t{btn2.x, btn2.y, btn2.w, 2}; SDL_RenderFillRect(st.r, &t);}
    {SDL_FRect l{btn2.x, btn2.y, 2, btn2.h}; SDL_RenderFillRect(st.r, &l);}
    // 下/右边框用亮色
    SDL_SetRenderDrawColor(st.r, LIGHT.r, LIGHT.g, LIGHT.b, 255);
    {SDL_FRect b{btn2.x, btn2.y+btn2.h-2, btn2.w, 2}; SDL_RenderFillRect(st.r, &b);}
    {SDL_FRect ri{btn2.x+btn2.w-2, btn2.y, 2, btn2.h}; SDL_RenderFillRect(st.r, &ri);}

    SDL_RenderPresent(st.r);
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void* s, SDL_AppResult)
{
    auto* st = static_cast<State*>(s);
    if (st) {
        if (st->r) SDL_DestroyRenderer(st->r);
        if (st->w) SDL_DestroyWindow(st->w);
        delete st;
    }
    SDL_Quit();
}

小提示 :你可以把它改名成 main.cpp 单独编译运行,或者作为扫雷游戏 renderer.cpp 的一部分。

5.3 颜色常量

扫雷项目使用的颜色表:

常量 用途
COLOR_UNREVEALED {192,192,192,255} 未翻开的格子(凸起表面)
COLOR_REVEALED {220,220,220,255} 已翻开的格子(扁平面)
COLOR_BG {180,180,180,255} 窗口背景
COLOR_LIGHT {255,255,255,255} 3D 亮色边框
COLOR_DARK {128,128,128,255} 3D 暗色边框
COLOR_EXPLODED {255,0,0,255} 踩雷的红色高亮

第六章:项目结构与类型定义

6.1 为什么要拆分文件?

扫雷游戏涉及多种职责:

复制代码
游戏逻辑(布雷、翻格、判定)→ 纯 C++,可测试
文字渲染(TTF 缓存)        → 依赖 SDL_ttf
棋盘渲染(画格子、UI)      → 依赖 SDL_Renderer
事件处理(鼠标、键盘)      → 依赖 SDL_Event
入口(回调函数)            → 胶水代码

所有代码堆在 main.cpp 会导致难以理解。合理拆分让每个文件职责单一。

最终文件结构:

复制代码
src/
  main.cpp     --- SDL3 回调入口
  types.hpp    --- 公共枚举和结构体
  game.hpp     --- GameState 聚合状态
  board.hpp    --- Board 游戏逻辑声明
  board.cpp    --- Board 游戏逻辑实现
  text.hpp     --- TextCache 文字缓存声明
  text.cpp     --- TextCache 文字缓存实现
  renderer.hpp --- MinesweeperRenderer 声明
  renderer.cpp --- MinesweeperRenderer 实现

6.2 类型定义 types.hpp

创建 src/types.hpp

cpp 复制代码
/**
 * @file types.hpp
 * @brief 扫雷游戏的公共类型定义
 */
#pragma once
#include <array>
#include <cstdint>
#include <string_view>

/// @brief 格子的显示状态
enum class CellState : uint8_t {
    Hidden,   ///< 未翻开
    Revealed, ///< 已翻开
    Flagged   ///< 已插旗
};

/// @brief 游戏状态
enum class GameStatus : uint8_t {
    Playing, ///< 游戏中
    Won,     ///< 已获胜
    Lost     ///< 已失败
};

/// @brief 难度等级
enum class Difficulty : uint8_t {
    Beginner,     ///< 初级:9×9,10 个雷
    Intermediate, ///< 中级:16×16,40 个雷
    Expert        ///< 高级:16×30,99 个雷
};

/// @brief 棋盘坐标(行列)
struct Coord {
    int row, col;
    constexpr bool operator==(const Coord&) const noexcept = default;
};

/// @brief 单个格子的数据(位域压缩,1 字节)
struct Cell {
    bool is_mine : 1 = false;
    CellState state : 2 = CellState::Hidden;
    uint8_t adjacent_mines : 4 = 0;
    uint8_t _pad : 1 = 0;
};

/// @brief 难度配置
struct DifficultyConfig {
    int rows, cols, mine_count;
    std::string_view name;
};

/// @brief 所有难度预设
inline constexpr std::array DIFFICULTIES = {
    DifficultyConfig{9,  9,  10, "Beginner"},
    DifficultyConfig{16, 16, 40, "Intermediate"},
    DifficultyConfig{16, 30, 99, "Expert"},
};

inline constexpr const DifficultyConfig& get_config(Difficulty d) {
    return DIFFICULTIES[static_cast<size_t>(d)];
}

// ── 布局常量 ────────────────────────────────────────────
inline constexpr int CELL_SIZE         = 36;
inline constexpr int HEADER_HEIGHT     = 90;
inline constexpr int SMILEY_SIZE       = 36;
inline constexpr int DIFF_BUTTON_WIDTH  = 90;
inline constexpr int DIFF_BUTTON_HEIGHT = 24;

inline constexpr int window_width(Difficulty d) {
    return get_config(d).cols * CELL_SIZE + 40;
}
inline constexpr int window_height(Difficulty d) {
    return get_config(d).rows * CELL_SIZE + HEADER_HEIGHT + 40;
}

/// @brief 数字颜色表(1=蓝, 2=绿, 3=红, 4=深蓝, 5=深红, 6=青, 7=黑, 8=灰)
inline constexpr std::array<uint32_t, 9> NUMBER_COLORS = {
    0x000000FF, 0x0000FFFF, 0x008000FF, 0xFF0000FF,
    0x000080FF, 0x800000FF, 0x008080FF, 0x000000FF, 0x808080FF,
};

关于位域Cell 结构体用 : 1: 2: 4 指定每个成员的位宽。扫雷最大棋盘 16×30 = 480 个格子,每个格子精确 1 字节,总共只用 480 字节。

6.3 聚合状态 game.hpp

cpp 复制代码
/**
 * @file game.hpp
 * @brief GameState 聚合状态
 */
#pragma once
#include <cstdint>
#include <optional>
struct SDL_Window;
struct SDL_Renderer;
#include "types.hpp"
#include "board.hpp"
#include "text.hpp"
#include "renderer.hpp"

struct GameState {
    Board board;
    GameStatus status = GameStatus::Playing;
    Difficulty difficulty = Difficulty::Beginner;

    uint64_t start_time = 0;
    uint64_t elapsed_seconds = 0;
    bool timer_running = false;

    SDL_Window* window = nullptr;
    SDL_Renderer* renderer = nullptr;

    std::optional<TextCache> text_cache;
    std::optional<MinesweeperRenderer> game_renderer;

    int window_w = 0, window_h = 0;
    
    template<typename Self>
    Self& reset_difficulty(this Self& self, Difficulty d) {
        self.difficulty = d;
        self.board.reset(d);
        self.status = GameStatus::Playing;
        self.timer_running = false;
        self.start_time = 0;
        self.elapsed_seconds = 0;
        return self;
    }
};

第七章:棋盘游戏逻辑(Board 类)

7.1 Board 的设计原则

Board 类完全独立于 SDL------不包含任何 SDL 头文件、不使用 SDL 类型。这带来两个好处:

  1. 可测试:可以在命令行中测试 Board 而不启动 SDL
  2. 可替换:如果以后换成其他渲染库,Board 不需要修改

7.2 头文件 board.hpp

cpp 复制代码
/**
 * @file board.hpp
 * @brief 棋盘游戏逻辑
 */
#pragma once
#include <vector>
#include <queue>
#include <random>
#include "types.hpp"

class Board {
public:
    Board() = default;
    explicit Board(Difficulty diff) { reset(diff); }

    void reset(Difficulty diff);             // 重置棋盘
    GameStatus reveal(Coord pos);            // 翻开格子
    GameStatus toggle_flag(Coord pos);       // 标旗/取消
    GameStatus chord_reveal(Coord pos);      // 和弦展开(双击数字)

    [[nodiscard]] const Cell& cell_at(Coord pos) const;
    [[nodiscard]] int rows()        const noexcept { return rows_; }
    [[nodiscard]] int cols()        const noexcept { return cols_; }
    [[nodiscard]] int mine_count()  const noexcept { return mine_count_; }
    [[nodiscard]] int flag_count()  const noexcept;
    [[nodiscard]] int cells_revealed() const noexcept;
    [[nodiscard]] Difficulty difficulty() const noexcept { return diff_; }
    [[nodiscard]] GameStatus status() const noexcept { return status_; }
    [[nodiscard]] bool is_first_click() const noexcept { return first_click_; }

private:
    void place_mines(Coord first_safe);     // Fisher-Yates 布雷
    void count_adjacent();                  // 计算相邻雷数
    void flood_fill(Coord pos);             // BFS 零区展开
    [[nodiscard]] std::vector<Coord> neighbors(Coord pos) const;
    [[nodiscard]] bool in_bounds(Coord pos) const noexcept {
        return pos.row >= 0 && pos.row < rows_
            && pos.col >= 0 && pos.col < cols_;
    }
    [[nodiscard]] int index_of(Coord pos) const noexcept {
        return pos.row * cols_ + pos.col;
    }
    [[nodiscard]] bool check_win() const noexcept;

    std::vector<Cell> cells_;        // 扁平存储: cells_[row*cols+col]
    int rows_ = 0, cols_ = 0, mine_count_ = 0;
    Difficulty diff_ = Difficulty::Beginner;
    GameStatus status_ = GameStatus::Playing;
    bool first_click_ = true;
    std::mt19937 rng_{std::random_device{}()};  // 随机数生成器
};

7.3 实现 board.cpp --- resetplace_mines

cpp 复制代码
#include "board.hpp"
#include <algorithm>
#include <print>
#include <ranges>

void Board::reset(Difficulty diff) {
    auto cfg = get_config(diff);
    rows_ = cfg.rows; cols_ = cfg.cols;
    mine_count_ = cfg.mine_count;
    diff_ = diff;
    status_ = GameStatus::Playing;
    first_click_ = true;

    cells_.clear();
    cells_.resize(static_cast<size_t>(rows_) * cols_);

    std::println("[Board] 棋盘已重置:{}×{},{} 个雷",
                 rows_, cols_, mine_count_);
}

布雷算法:Fisher-Yates 洗牌

复制代码
原理:把所有非首击位置的索引放入列表
     → 洗牌(交换随机位置)
     → 取前 mine_count_ 个作为雷位

     [0] [1] [2] [3] [4] [5] ...
      ↑_______↑ 交换
     雷位  随机位
     [*] [ ] [*] [ ] [ ] [ ] ...
cpp 复制代码
void Board::place_mines(Coord first_safe) {
    const int total = rows_ * cols_;
    std::vector<int> indices;
    indices.reserve(total - 1);

    const int safe_idx = index_of(first_safe);
    for (int i = 0; i < total; ++i)
        if (i != safe_idx) indices.push_back(i);

    for (int i = 0; i < mine_count_; ++i) {
        std::uniform_int_distribution<int> dist(
            i, static_cast<int>(indices.size()) - 1);
        std::swap(indices[i], indices[dist(rng_)]);
        cells_[indices[i]].is_mine = true;
    }
}

7.4 count_adjacent --- 计算每个格子的相邻雷数

cpp 复制代码
void Board::count_adjacent() {
    for (int r = 0; r < rows_; ++r) {
        for (int c = 0; c < cols_; ++c) {
            if (cells_[index_of({r, c})].is_mine) {
                for (const auto& n : neighbors({r, c})) {
                    auto& nc = cells_[index_of(n)];
                    if (!nc.is_mine) nc.adjacent_mines++;
                }
            }
        }
    }
}

7.5 revealflood_fill --- 翻开与展开

当点击一个格子:

  1. 若是首击 → 布雷 + 计数
  2. 若是雷 → 游戏失败
  3. 若相邻雷数 > 0 → 翻开这个格子,显示数字
  4. 若相邻雷数 = 0 → Flood Fill 展开

Flood Fill 用 BFS(广度优先搜索):

复制代码
    点击 (2,3),该格为 0
      ↓
    将 (2,3) 的邻居加入队列
      ↓
    逐个处理队列中的格子:
      · 是 0 → 将其邻居也加入队列
      · 是数字 → 翻开但不展开
cpp 复制代码
GameStatus Board::reveal(Coord pos) {
    if (status_ != GameStatus::Playing) return status_;
    if (!in_bounds(pos)) return status_;

    auto& cell = cells_[index_of(pos)];
    if (cell.state == CellState::Revealed
     || cell.state == CellState::Flagged)
        return status_;

    // 首击布雷(保证首击安全)
    if (first_click_) [[unlikely]] {
        first_click_ = false;
        place_mines(pos);
        count_adjacent();
    }

    if (cell.is_mine) {
        cell.state = CellState::Revealed;
        status_ = GameStatus::Lost;
        return status_;
    }

    if (cell.adjacent_mines == 0)
        flood_fill(pos);
    else
        cell.state = CellState::Revealed;

    if (check_win()) {
        status_ = GameStatus::Won;
        std::println("[Board] 恭喜!扫雷成功!");
    }
    return status_;
}

void Board::flood_fill(Coord start) {
    std::queue<Coord> q;
    q.push(start);

    while (!q.empty()) {
        Coord pos = q.front(); q.pop();
        auto& cell = cells_[index_of(pos)];

        if (cell.state == CellState::Revealed
         || cell.state == CellState::Flagged
         || cell.is_mine)
            continue;

        cell.state = CellState::Revealed;

        if (cell.adjacent_mines == 0) {
            for (const auto& n : neighbors(pos))
                q.push(n);
        }
    }
}

7.6 和弦展开---双击数字自动打开周围格子

cpp 复制代码
GameStatus Board::chord_reveal(Coord pos) {
    if (status_ != GameStatus::Playing) return status_;
    if (!in_bounds(pos)) return status_;

    auto& cell = cells_[index_of(pos)];
    if (cell.state != CellState::Revealed || cell.adjacent_mines == 0)
        return status_;

    // 统计周围旗帜数
    auto nbrs = neighbors(pos);
    int adj_flags = 0;
    for (const auto& n : nbrs) {
        if (cells_[index_of(n)].state == CellState::Flagged)
            adj_flags++;
    }
    if (adj_flags != cell.adjacent_mines) return status_;

    // 展开
    bool hit = false;
    for (const auto& n : nbrs) {
        auto& nc = cells_[index_of(n)];
        if (nc.state == CellState::Hidden) {
            if (nc.is_mine) { nc.state = CellState::Revealed; hit = true; }
            else if (nc.adjacent_mines == 0) flood_fill(n);
            else nc.state = CellState::Revealed;
        }
    }

    if (hit) { status_ = GameStatus::Lost; return status_; }
    if (check_win()) status_ = GameStatus::Won;
    return status_;
}

7.7 其他辅助方法

cpp 复制代码
GameStatus Board::toggle_flag(Coord pos) {
    if (status_ != GameStatus::Playing || !in_bounds(pos))
        return status_;
    auto& cell = cells_[index_of(pos)];
    if (cell.state == CellState::Hidden)
        cell.state = CellState::Flagged;
    else if (cell.state == CellState::Flagged)
        cell.state = CellState::Hidden;
    return status_;
}

const Cell& Board::cell_at(Coord pos) const {
    return cells_[index_of(pos)];
}

int Board::flag_count() const noexcept {
    return static_cast<int>(std::ranges::count_if(cells_,
        [](const Cell& c) { return c.state == CellState::Flagged; }));
}

int Board::cells_revealed() const noexcept {
    return static_cast<int>(std::ranges::count_if(cells_,
        [](const Cell& c) { return c.state == CellState::Revealed; }));
}

std::vector<Coord> Board::neighbors(Coord pos) const {
    static constexpr std::array<std::pair<int,int>, 8> DIRS = {{
        {-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}
    }};
    std::vector<Coord> result; result.reserve(8);
    for (auto [dr, dc] : DIRS) {
        Coord n{pos.row+dr, pos.col+dc};
        if (in_bounds(n)) result.push_back(n);
    }
    return result;
}

bool Board::check_win() const noexcept {
    return cells_revealed() >= rows_ * cols_ - mine_count_;
}

第八章:文字渲染缓存(TextCache 类)

8.1 为什么需要缓存?

扫雷中需要反复显示数字(0-9)。如果每帧都用 TTF_RenderText_Solid 重新光栅化,会浪费大量 CPU 时间。

TextCache 的策略:启动时预渲染 10 个数字纹理,每帧只需贴图。

8.2 std::expected

传统 C++ 的构造函数无法返回错误。解决方案:

  • 用异常(性能差,扫雷不需要)
  • 用工厂方法 + 返回码(代码冗长)
  • ** std::expected<T, E>** --- 要么包含一个成功的值,要么包含一个错误
cpp 复制代码
// text.hpp
using Result = std::expected<TextCache, std::string>;
static Result create(SDL_Renderer* r, const std::string& font_path,
                     const std::string& emoji_font_path = "",
                     float font_size = 24.0f);

// 使用方
auto result = TextCache::create(renderer, "font.ttf");
if (result) {
    auto& cache = *result;  // 成功
} else {
    std::println("失败:{}", result.error());  // 失败
}

8.3 text.hpp 完整代码

cpp 复制代码
/**
 * @file text.hpp
 * @brief SDL_ttf 文字渲染缓存(双字体回退)
 */
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <expected>
#include <string>
#include <array>

class TextCache {
public:
    using Result = std::expected<TextCache, std::string>;

    static Result create(SDL_Renderer* renderer,
                         const std::string& font_path,
                         const std::string& emoji_font_path = "",
                         float font_size = 24.0f);

    ~TextCache();
    TextCache(TextCache&&) noexcept;
    TextCache& operator=(TextCache&&) noexcept;
    TextCache(const TextCache&) = delete;
    TextCache& operator=(const TextCache&) = delete;

    /// Solid 模式渲染(主字体,单色,最快)
    SDL_FRect render_text(SDL_Renderer* r, const char* text,
                          SDL_Color color, float x, float y,
                          float scale = 1.0f);

    /// Blended 模式渲染(emoji 字体优先,抗锯齿)
    SDL_FRect render_text_blended(SDL_Renderer* r, const char* text,
                                   SDL_Color color, float x, float y,
                                   float scale = 1.0f);

    SDL_FRect render_digit(SDL_Renderer* r, int digit,
                            SDL_Color color, float x, float y,
                            float scale = 1.0f);

    [[nodiscard]] float font_size() const noexcept { return font_size_; }

private:
    TextCache() = default;
    void cache_digits(SDL_Renderer* r);
    void destroy_textures() noexcept;
    SDL_Surface* try_blended(TTF_Font* f, const char* t, SDL_Color c) const;

    TTF_Font* font_ = nullptr;       // 主字体(普通文字)
    TTF_Font* emoji_font_ = nullptr; // emoji 回退字体
    float font_size_ = 24.0f;
    std::array<SDL_Texture*, 10> digit_textures_{};
};

8.4 text.cpp 核心实现

cpp 复制代码
#include "text.hpp"
#include <print>
#include <cstring>

// ── 工厂方法 ────────────────────────────────────────────
TextCache::Result TextCache::create(SDL_Renderer* renderer,
    const std::string& font_path, const std::string& emoji_font_path,
    float font_size)
{
    if (!renderer) return std::unexpected("渲染器为空");

    TTF_Font* font = TTF_OpenFont(font_path.c_str(), font_size);
    if (!font) return std::unexpected(
        std::format("无法打开字体 '{}':{}", font_path, SDL_GetError()));

    TextCache cache;
    cache.font_ = font;
    cache.font_size_ = font_size;

    // 尝试加载 emoji 回退字体(失败仅记录警告)
    if (!emoji_font_path.empty()) {
        cache.emoji_font_ = TTF_OpenFont(emoji_font_path.c_str(), font_size);
        if (cache.emoji_font_)
            std::println("[TextCache] emoji 字体已加载");
        else
            std::println("[TextCache] emoji 字体加载失败,将使用主字体");
    }

    cache.cache_digits(renderer);
    std::println("[TextCache] 主字体已加载({}pt)", font_size);
    return cache;
}

// ── Blended 渲染(emoji 优先 → 主字体 → Solid 兜底)───
SDL_FRect TextCache::render_text_blended(SDL_Renderer* renderer,
    const char* text, SDL_Color color, float x, float y, float scale)
{
    if (!text || text[0] == '\0') return {x, y, 0, 0};

    SDL_Surface* surface = nullptr;
    if (emoji_font_) surface = try_blended(emoji_font_, text, color);
    if (!surface && font_) surface = try_blended(font_, text, color);
    if (!surface) return render_text(renderer, text, color, x, y, scale);

    SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer, surface);
    if (!tex) { SDL_DestroySurface(surface);
        return render_text(renderer, text, color, x, y, scale); }

    auto props = SDL_GetTextureProperties(tex);
    float w = SDL_GetNumberProperty(props,
        SDL_PROP_TEXTURE_WIDTH_NUMBER, 0) * scale;
    float h = SDL_GetNumberProperty(props,
        SDL_PROP_TEXTURE_HEIGHT_NUMBER, 0) * scale;

    SDL_FRect dst{x, y, w, h};
    SDL_RenderTexture(renderer, tex, nullptr, &dst);
    SDL_DestroyTexture(tex);
    SDL_DestroySurface(surface);
    return dst;
}

关于 emoji 回退策略

主字体(Inter)不包含 emoji 字形。当我们调用 TTF_RenderText_Blended 渲染 "🤔" 时,Inter 字体会失败 → 自动切换到 NotoColorEmoji → 成功。这就是"字体回退"。


第九章:棋盘渲染器(MinesweeperRenderer)

9.1 渲染器设计

MinesweeperRenderer 负责所有绘制工作:

复制代码
┌────────────────────────────────────┐
│   [003]     [🤔]      [000]      │ 雷数 · 笑脸 · 计时器
│     [Beg]   [Int]   [Exp]        │ 难度按钮
├────────────────────────────────────┤  ← grid_offset_y_
│  ╔══╗╔══╗╔══╗╔══╗               │
│  ║  ║║1 ║║  ║║  ║               │  棋盘格
│  ╚══╝╚══╝╚══╝╚══╝               │
└────────────────────────────────────┘

9.2 renderer.hpp 头文件

cpp 复制代码
/**
 * @file renderer.hpp
 * @brief 棋盘渲染器
 */
#pragma once
#include <SDL3/SDL.h>
#include <optional>
#include "types.hpp"
#include "board.hpp"
#include "text.hpp"

class MinesweeperRenderer {
public:
    explicit MinesweeperRenderer(SDL_Renderer* r, TextCache& t)
        : renderer_(r), text_(&t) {}

    void draw_board(const Board& board);
    void draw_header(const Board& board, uint64_t elapsed_sec);
    void draw_game_over_overlay(const Board& board);

    [[nodiscard]] std::optional<Coord> screen_to_grid(float x, float y) const;
    [[nodiscard]] SDL_FRect grid_to_screen(Coord pos) const;
    [[nodiscard]] SDL_FRect smiley_rect() const;
    [[nodiscard]] SDL_FRect difficulty_button_rect(int index) const;

    void update_layout(int ww, int wh, const Board& board);

private:
    void draw_cell(const Board& board, Coord pos);
    void draw_raised_rect(const SDL_FRect& r, bool pressed = false);
    void draw_smiley(GameStatus status);
    void draw_mine_counter(int remaining);
    void draw_timer(uint64_t elapsed_sec);
    void draw_difficulty_buttons(Difficulty current);

    SDL_Renderer* renderer_;
    TextCache* text_;
    int grid_offset_x_ = 0, grid_offset_y_ = HEADER_HEIGHT;
    int board_width_ = 0, board_height_ = 0;

    static constexpr SDL_Color COLOR_UNREVEALED{192,192,192,255};
    static constexpr SDL_Color COLOR_REVEALED {220,220,220,255};
    static constexpr SDL_Color COLOR_BG       {180,180,180,255};
    static constexpr SDL_Color COLOR_LIGHT    {255,255,255,255};
    static constexpr SDL_Color COLOR_DARK     {128,128,128,255};
    static constexpr SDL_Color COLOR_EXPLODED {255,0,0,255};
    static constexpr SDL_Color COLOR_HEADER_BG{160,160,160,255};
};

9.3 坐标映射

cpp 复制代码
std::optional<Coord> MinesweeperRenderer::screen_to_grid(float x, float y) const {
    float gx = x - grid_offset_x_;
    float gy = y - grid_offset_y_;
    if (gx < 0 || gy < 0) return std::nullopt;
    int col = static_cast<int>(gx / CELL_SIZE);
    int row = static_cast<int>(gy / CELL_SIZE);
    return Coord{row, col};
}

SDL_FRect MinesweeperRenderer::grid_to_screen(Coord pos) const {
    return {
        static_cast<float>(grid_offset_x_ + pos.col * CELL_SIZE),
        static_cast<float>(grid_offset_y_ + pos.row * CELL_SIZE),
        static_cast<float>(CELL_SIZE),
        static_cast<float>(CELL_SIZE)
    };
}

9.4 绘制格子

cpp 复制代码
void MinesweeperRenderer::draw_cell(const Board& board, Coord pos) {
    const Cell& cell = board.cell_at(pos);
    SDL_FRect rect = grid_to_screen(pos);

    switch (cell.state) {
    case CellState::Hidden:
        draw_raised_rect(rect, false);   // 凸起
        break;

    case CellState::Revealed:
        if (cell.is_mine) {
            // 红色高亮背景
            SDL_SetRenderDrawColor(renderer_, 255, 0, 0, 255);
            SDL_RenderFillRect(renderer_, &rect);
        } else {
            // 扁平浅色背景
            SDL_SetRenderDrawColor(renderer_, COLOR_REVEALED.r,
                COLOR_REVEALED.g, COLOR_REVEALED.b, 255);
            SDL_RenderFillRect(renderer_, &rect);
            // 细边框
            SDL_SetRenderDrawColor(renderer_, COLOR_DARK.r,
                COLOR_DARK.g, COLOR_DARK.b, 255);
            SDL_RenderRect(renderer_, &rect);

            // 显示数字
            if (cell.adjacent_mines > 0) {
                uint32_t cv = NUMBER_COLORS[cell.adjacent_mines];
                SDL_Color nc{uint8_t(cv>>24), uint8_t(cv>>16),
                             uint8_t(cv>>8), 255};
                char d[2]{char('0'+cell.adjacent_mines), '\0'};
                text_->render_text(renderer_, d, nc,
                                   rect.x+10, rect.y+4, 1.0f);
            }
        }
        break;

    case CellState::Flagged:
        draw_raised_rect(rect, false);
        text_->render_text(renderer_, "F", {255,0,0,255},
                           rect.x+9, rect.y+2, 1.0f);
        break;
    }
}

9.5 信息栏绘制

cpp 复制代码
void MinesweeperRenderer::draw_header(const Board& board, uint64_t elapsed) {
    // 背景
    SDL_FRect bg{0, 0, float(board_width_), float(HEADER_HEIGHT)};
    SDL_SetRenderDrawColor(renderer_, COLOR_HEADER_BG.r,
        COLOR_HEADER_BG.g, COLOR_HEADER_BG.b, 255);
    SDL_RenderFillRect(renderer_, &bg);

    draw_mine_counter(board.mine_count() - board.flag_count());
    draw_smiley(board.status());
    draw_timer(elapsed);
    draw_difficulty_buttons(board.difficulty());
}

void MinesweeperRenderer::draw_smiley(GameStatus status) {
    auto rect = smiley_rect();
    draw_raised_rect(rect, false);

    const char* face;
    SDL_Color fc;
    switch (status) {
    case GameStatus::Playing: face = "🤔"; fc = {255,255,0,255}; break;
    case GameStatus::Won:     face = "😎"; fc = {0,255,0,255};   break;
    case GameStatus::Lost:    face = "😭"; fc = {255,0,0,255};   break;
    default:                  face = "🙂"; fc = {255,255,0,255}; break;
    }

    text_->render_text_blended(renderer_, face, fc,
                                rect.x+2, rect.y+6, 0.9f);
}

第十章:将一切组装起来(main.cpp)

现在所有组件都已就绪,是时候在 main.cpp 中把它们串联起来了。

10.1 SDL_AppInit --- 启动流程

cpp 复制代码
SDL_AppResult SDL_AppInit(void** appstate, int, char*[]) {
    // 1. 初始化 SDL
    if (!SDL_Init(SDL_INIT_VIDEO)) return SDL_APP_FAILURE;
    if (!TTF_Init()) return SDL_APP_FAILURE;

    // 2. 窗口 + 渲染器
    auto diff = Difficulty::Beginner;
    SDL_Window* win = SDL_CreateWindow("SDL3 扫雷",
        window_width(diff), window_height(diff),
        SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
    SDL_Renderer* rend = SDL_CreateRenderer(win, nullptr);

    // 3. 获取字体路径
    auto* bp = SDL_GetBasePath();
    std::string base = bp;
    SDL_free(bp);

    // 4. 创建 TextCache(std::expected)
    auto tc = TextCache::create(rend,
        base + "Inter-VariableFont.ttf",
        base + "NotoColorEmoji-Regular.ttf", 24.0f);
    if (!tc) {
        std::println(stderr, "字体加载失败:{}", tc.error());
        return SDL_APP_FAILURE;
    }

    // 5. 创建 GameState 和 Renderer
    auto* state = new GameState{
        .board = Board(diff),
        .window = win, .renderer = rend,
        .text_cache = std::move(*tc),
    };
    state->game_renderer.emplace(rend, *state->text_cache);

    SDL_GetWindowSize(win, &state->window_w, &state->window_h);
    state->game_renderer->update_layout(
        state->window_w, state->window_h, state->board);
    SDL_SetRenderVSync(rend, 1);
    SDL_ShowWindow(win);

    *appstate = state;
    std::println("扫雷已启动!左键翻开 | 右键标旗 | 双击和弦 | 1/2/3 难度 | R 重来");
    return SDL_APP_CONTINUE;
}

关键点std::move(*tc) --- TextCache 不可拷贝(它拥有 GPU 纹理),必须用移动语义转移所有权。这是 C++11 引入的重要概念。

10.2 SDL_AppEvent --- 事件处理

cpp 复制代码
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event) {
    auto& s = *static_cast<GameState*>(appstate);

    switch (event->type) {
    case SDL_EVENT_QUIT:
        return SDL_APP_SUCCESS;

    case SDL_EVENT_MOUSE_BUTTON_DOWN: {
        float mx = event->button.x, my = event->button.y;
        uint8_t btn = event->button.button;
        SDL_FPoint pt{mx, my};

        // 笑脸按钮 → 重来
        auto sr = s.game_renderer->smiley_rect();
        if (SDL_PointInRectFloat(&pt, &sr)) {
            s.reset_difficulty(s.difficulty);
            s.game_renderer->update_layout(s.window_w, s.window_h, s.board);
            return SDL_APP_CONTINUE;
        }

        // 难度按钮
        for (int i = 0; i < 3; ++i) {
            auto dr = s.game_renderer->difficulty_button_rect(i);
            if (SDL_PointInRectFloat(&pt, &dr)) {
                auto d = static_cast<Difficulty>(i);
                if (d != s.difficulty) {
                    s.reset_difficulty(d);
                    SDL_SetWindowSize(s.window,
                        window_width(d), window_height(d));
                    SDL_GetWindowSize(s.window, &s.window_w, &s.window_h);
                    s.game_renderer->update_layout(
                        s.window_w, s.window_h, s.board);
                }
                return SDL_APP_CONTINUE;
            }
        }

        // 棋盘点击
        if (s.status != GameStatus::Playing)
            return SDL_APP_CONTINUE;

        auto coord = s.game_renderer->screen_to_grid(mx, my);
        if (!coord) return SDL_APP_CONTINUE;

        // 左键
        if (btn == SDL_BUTTON_LEFT) {
            if (event->button.clicks >= 2)
                s.status = s.board.chord_reveal(*coord);  // 双击和弦
            else
                s.status = s.board.reveal(*coord);        // 单击翻开

            if (!s.timer_running && s.status == GameStatus::Playing) {
                s.start_time = SDL_GetTicks();
                s.timer_running = true;
            }
            if (s.status != GameStatus::Playing)
                s.timer_running = false;
        }
        // 右键标旗
        else if (btn == SDL_BUTTON_RIGHT) {
            s.board.toggle_flag(*coord);
        }
        return SDL_APP_CONTINUE;
    }

    // 键盘快捷键
    case SDL_EVENT_KEY_DOWN:
        switch (event->key.key) {
        case SDLK_1: s.reset_difficulty(Difficulty::Beginner);   break;
        case SDLK_2: s.reset_difficulty(Difficulty::Intermediate); break;
        case SDLK_3: s.reset_difficulty(Difficulty::Expert);     break;
        case SDLK_R: s.reset_difficulty(s.difficulty);           break;
        default: return SDL_APP_CONTINUE;
        }
        {
            auto d = s.difficulty;
            SDL_SetWindowSize(s.window, window_width(d), window_height(d));
            SDL_GetWindowSize(s.window, &s.window_w, &s.window_h);
            s.game_renderer->update_layout(s.window_w, s.window_h, s.board);
        }
        return SDL_APP_CONTINUE;
    }
    return SDL_APP_CONTINUE;
}

SDL3 教学event->button.clicks --- SDL3 自动追踪双击次数,不需要手动计算时间间隔!这是 SDL2 没有的功能。

10.3 SDL_AppIterate --- 每帧渲染

cpp 复制代码
SDL_AppResult SDL_AppIterate(void* appstate) {
    auto& s = *static_cast<GameState*>(appstate);

    if (s.timer_running)
        s.elapsed_seconds = (SDL_GetTicks() - s.start_time) / 1000;

    // 灰色清屏
    SDL_SetRenderDrawColor(s.renderer, 180, 180, 180, 255);
    SDL_RenderClear(s.renderer);

    // 绘制信息栏 + 棋盘
    s.game_renderer->draw_header(s.board, s.elapsed_seconds);
    s.game_renderer->draw_board(s.board);

    // 游戏结束覆盖层
    if (s.status == GameStatus::Lost)
        s.game_renderer->draw_game_over_overlay(s.board);

    SDL_RenderPresent(s.renderer);
    return SDL_APP_CONTINUE;
}

10.4 SDL_AppQuit --- 清理

cpp 复制代码
void SDL_AppQuit(void* appstate, SDL_AppResult) {
    auto* s = static_cast<GameState*>(appstate);
    if (!s) return;

    // 逆序销毁:先子后父
    s->game_renderer.reset();  // 依赖 renderer,先销毁
    s->text_cache.reset();     // 依赖 renderer,先销毁
    if (s->renderer) SDL_DestroyRenderer(s->renderer);
    if (s->window)   SDL_DestroyWindow(s->window);

    TTF_Quit();
    // SDL3 Callback API 会自动调用 SDL_Quit(),不要手动调用!

    delete s;
    std::println("扫雷游戏已退出。");
}

重要 :SDL3 回调模式下,不要手动调用 SDL_Quit() 。SDL 会在 SDL_AppQuit 返回后自动调用它。手动调用会导致双重释放(double-free),即之前遇到的 Debug Heap Assertion。


第十一章:配置 CMakeLists.txt

在章节 2.3 的基础上,更新 CMakeLists.txt 以包含所有新源文件:

cmake 复制代码
cmake_minimum_required(VERSION 3.16)
# ...前面内容不变...

# 源文件(新增 board.cpp renderer.cpp text.cpp)
target_sources(${PROJECT_NAME} PRIVATE
    src/main.cpp
    src/board.cpp
    src/renderer.cpp
    src/text.cpp
)

# ...中间内容不变...

# 复制字体到输出目录
macro(copy_helper filename)
    if (ANDROID)
        set(outname "${MOBILE_ASSETS_DIR}/${filename}")
    else()
        set(outname "${CMAKE_BINARY_DIR}/$<CONFIGURATION>/${filename}")
    endif()
    add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
            "${CMAKE_CURRENT_LIST_DIR}/src/${filename}"
            "${outname}")
endmacro()

copy_helper("Inter-VariableFont.ttf")
copy_helper("NotoColorEmoji-Regular.ttf")

第十二章:构建与运行

12.1 最终项目结构

复制代码
minesweeper-tutorial/
├── CMakeLists.txt
├── CMakePresets.json
├── .clangd
├── SDL/            # vendored SDL3
├── SDL_ttf/        # vendored SDL_ttf
├── build/          # 构建输出
└── src/
    ├── main.cpp
    ├── types.hpp
    ├── game.hpp
    ├── board.hpp
    ├── board.cpp
    ├── text.hpp
    ├── text.cpp
    ├── renderer.hpp
    ├── renderer.cpp
    ├── Inter-VariableFont.ttf
    └── NotoColorEmoji-Regular.ttf

12.2 编译命令

powershell 复制代码
# 首次配置(只需一次)
cmake --preset default

# 编译
cmake --build build/default

# 运行
.\build\default\Debug\minesweeper.exe

12.3 预期效果

复制代码
┌──────────────────────────────────────────────┐
│  [010]              [🤔]              [000]  │
│         [Beg]      [Int]      [Exp]          │
├──────────────────────────────────────────────┤
│  ╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗    │
│  ║🤔║║1 ║║  ║║  ║║  ║║  ║║  ║║  ║║  ║    │
│  ╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝    │
│  ╔══╗╔══╗╔══╗╔══╗╔══╗                           │
│  ║2 ║║2 ║║1 ║║1 ║║1 ║                           │
│  ╚══╝╚══╝╚══╝╚══╝╚══╝                           │
│  ...                                           │
└──────────────────────────────────────────────┘

左侧是雷数计数器,中间是笑脸(点击重新开始),右侧是计时器。


第十三章:常见问题与调试技巧

13.1 构建问题

cmake 报 "SDL3::SDL3 not found"

SDL 源码可能在错误的路径。确认 SDL/ 目录包含 CMakeLists.txt(即 SDL 源码根目录)。

字体文件找不到

确认 Inter-VariableFont.ttfNotoColorEmoji-Regular.ttfsrc/ 目录,且 CMakeLists.txt 中的 copy_helper 正确配置。在运行 exe 的同一目录下应该能看到这些 .ttf 文件。

编译速度慢

CMakeLists.txt 中已经开启了 MSVC 的 /MP 并行编译。如果使用 Ninja,它默认并行编译,不需要特殊设置。

13.2 运行时问题

窗口一闪就消失

检查 SDL_AppInit 中是否正确处理了错误返回值。添加 SDL_GetError() 日志可以帮助定位问题。

点击格子没反应

  1. 确认 screen_to_grid 的边界条件:坐标必须在棋盘区域(grid_offset_y_ 以下)
  2. 确认鼠标事件正确捕获:event->type == SDL_EVENT_MOUSE_BUTTON_DOWN

退出时 Debug Heap Assertion

不要在 SDL_AppQuit 中调用 SDL_Quit()。SDL3 回调模式会自动处理 SDL_Quit。


相关推荐
feng_you_ying_li1 小时前
Linux 之线程封装,线程的同步与互斥,互斥锁的介绍
linux·c++·算法
星恒随风1 小时前
C++入门(二):函数重载、引用、const引用和 inline 内联函数
开发语言·c++·笔记·学习
basketball6161 小时前
C++ 高级编程:1. 多线程基本操作
开发语言·c++
十五年专注C++开发1 小时前
std::vector<T>到QVector<T>的数据复制方案
c++·vector·iterator模式·qvector
小欣加油12 小时前
leetcode3751 范围内总波动值I
java·数据结构·c++·算法·leetcode
代码中介商13 小时前
C++左值与右值:核心判断法则详解
开发语言·c++
玖玥拾13 小时前
C/C++ 基础笔记(七)
c语言·c++
珊瑚里的鱼14 小时前
手撕单例模式中的饿汉模式和懒汉模式,懒汉模式还要再多加一个C++11版本的
开发语言·c++·单例模式
zh路西法14 小时前
【Linux 串口通信】基于 C++ 多线程的同步/异步串口实现
linux·运维·c++·python