用 C++ 和 Win32 从零写一个扫雷:从经典灰色到 Windows 7 蓝色动画

• 75 分钟阅读 • code

一次完整的「小游戏开发」复盘:零第三方依赖,纯 C++ + Win32 GDI,从能玩,到好看,再到会动。


为什么要写这篇

扫雷看似简单,却是练手 GUI 编程的绝佳项目:它同时涉及游戏逻辑(布雷、洪泛展开、胜负判定)、界面渲染(双缓冲、渐变、七段数码管)和动画系统(翻开波浪、爆炸效果)。

本文记录我用纯 C++(Win32 API + GDI,不依赖 Qt/SDL 等任何第三方库)实现一个扫雷游戏的完整过程,包括架构拆分、两种美术风格的重做,以及几个真实踩过的坑。


一、技术选型:为什么是 Win32

目标很明确:一个可双击运行的 exe,不依赖任何运行库。

几个常见方案对比:

方案 优点 缺点
Qt 组件丰富、跨平台 体积大、需带一堆 DLL
SDL / SFML 游戏向、好用 仍需额外库
Win32 + GDI 系统自带、零依赖、单 exe 代码相对啰嗦,全部手绘

最终选 Win32:一个 .cpp 文件、一行 cl 命令就能出 exe。所有界面元素——格子、数字、旗帜、地雷、计数器、笑脸——全部用 GDI 一笔一画地画出来,没有用任何现成控件。


二、整体架构:从单文件到四模块

第一版是一个 700 行的单文件。虽然能跑,但逻辑、渲染、窗口消息全挤在一起,维护困难。于是按职责拆成了四个模块:

minesweeper/
├── game.h / game.cpp        # 游戏核心逻辑(纯逻辑,不碰任何窗口/绘图)
├── render.h / render.cpp    # 全部 GDI 绘制 + 动画
├── dialogs.h / dialogs.cpp  # 「自定义难度」对话框
├── app.cpp                  # WinMain、窗口、消息循环、菜单、定时器
└── build.bat                # 一行 cl 编译

关键设计:逻辑与渲染彻底解耦。

// game.h —— 纯逻辑,不 include <windows.h>
class Game {
public:
    void NewGame(int rows, int cols, int mines);
    void Reveal(int r, int c);
    void Chord(int r, int c);
    void ToggleFlag(int r, int c);
    void Tick();
    const std::vector<std::vector<Cell>>& Board() const;
    GameState State() const;
    // ...
};

三、核心游戏逻辑

1. 首次点击保证安全

经典扫雷的体验底线:第一下不能踩雷。做法是延迟布雷——第一次翻开时才生成地雷,且避开点击处及其周围 8 格:

void Game::PlaceMines(int safeR, int safeC) {
    int placed = 0;
    while (placed < m_mines) {
        int r = rand() % m_rows, c = rand() % m_cols;
        if (m_board[r][c].mine) continue;
        if (abs(r - safeR) <= 1 && abs(c - safeC) <= 1) continue; // 安全区
        m_board[r][c].mine = true;
        ++placed;
    }
    // 再遍历一遍,计算每个格子的相邻雷数 adj
}

2. 洪泛展开(Flood Fill)

点开一个「0」格子时,要自动向四周扩散,直到被数字包围。用显式栈代替递归,避免深递归爆栈:

std::vector<std::pair<int,int>> stack;
stack.push_back({r, c});
while (!stack.empty()) {
    auto [rr, cc] = stack.back();
    stack.pop_back();
    Cell& cur = m_board[rr][cc];
    if (cur.revealed || cur.flagged) continue;
    cur.revealed = true;
    if (cur.adj == 0) {
        // 把周围 8 格压栈继续展开
        for (int dr = -1; dr <= 1; ++dr)
            for (int dc = -1; dc <= 1; ++dc) { /* ... */ }
    }
}

3. 左右键同按(Chord)

在已翻开的数字上同时按下左右键,如果周围旗子数等于该数字,就自动翻开周围所有未标记的格子——扫雷老玩家必备的提速操作。

4. 胜负判定


四、界面渲染:全部手绘

双缓冲,杜绝闪烁

每次重绘先画到内存位图,再一次性贴到屏幕:

void RenderClient(HDC hdc, const Game& game, bool smileDown, const RECT& client) {
    int w = client.right - client.left, h = client.bottom - client.top;
    HDC mem = CreateCompatibleDC(hdc);
    HBITMAP bmp = CreateCompatibleBitmap(hdc, w, h);
    HBITMAP oldb = (HBITMAP)SelectObject(mem, bmp);

    DrawGame(mem, client, game, smileDown);   // 全部画在内存 DC 上

    BitBlt(hdc, 0, 0, w, h, mem, 0, 0, SRCCOPY); // 一次性贴出
    SelectObject(mem, oldb);
    DeleteObject(bmp);
    DeleteDC(mem);
}

七段数码管计数器

经典的「剩余雷数 / 计时」是红色 LED 七段数码管。不用字体,直接按位图逐段画:

enum Seg { SEG_A=1<<0, SEG_B=1<<1, SEG_C=1<<2, SEG_D=1<<3,
           SEG_E=1<<4, SEG_F=1<<5, SEG_G=1<<6 };

// 0~9 各自的点亮段位
const BYTE g_segDigits[10] = {
    0b0111111, // 0
    0b0000110, // 1
    0b1011011, // 2
    // ...
};

void DrawDigit7(HDC hdc, int x, int y, int w, int h, int digit) {
    BYTE m = g_segDigits[digit];
    if (m & SEG_A) SegRect(hdc, x + t, y, w - 2*t, t);  // 上横
    if (m & SEG_F) SegRect(hdc, x, y + t, t, mid - t);  // 左上竖
    // ... 七段各自判断
}

两种美术风格的演进

这其实是我最喜欢的一段经历——同一套逻辑,两套皮肤:

版本 A:经典 Windows 98/XP 风格(灰色 + 3D 凹凸边框)

未翻开格子用四级 3D 立体边框,逐像素验证过层次:

255(白高光) → 223(浅灰) → 192(灰面) → 64(深阴影) → 128(阴影) → 下一格…

版本 B:Windows 7 蓝色 Aero 风格(渐变 + 光泽 + 动画)

改用 GradientFill 画垂直渐变,格子像磨砂玻璃按钮:

void FillGradientV(HDC hdc, const RECT& rc, COLORREF top, COLORREF bottom) {
    TRIVERTEX v[2];
    v[0].x = rc.left;  v[0].y = rc.top;
    v[0].Red   = GetRValue(top) << 8;   // 颜色分量左移 8 位
    v[0].Green = GetGValue(top) << 8;
    v[0].Blue  = GetBValue(top) << 8;
    v[1].x = rc.right; v[1].y = rc.bottom;
    // ... 同理填 v[1]
    GRADIENT_RECT gr = { 0, 1 };
    GradientFill(hdc, v, 2, &gr, 1, GRADIENT_FILL_RECT_V);
}

注意:GradientFill 位于 msimg32.lib,链接时要带上。


五、动画系统:让格子「活」起来

Windows 7 版最打动人心的,是那些细腻的动画。核心思路很朴素:

给每个格子存一个「动画时间戳」,渲染时拿当前时间相减算出「动画进度」,据此决定画多大、画不画。

struct Cell {
    // ...
    unsigned revealStamp = 0;  // 翻开动画起点
    unsigned flagStamp   = 0;  // 插旗动画起点
    unsigned mineStamp   = 0;  // 地雷爆炸动画起点
};

翻开波浪(扩散波纹)

这是最出彩的效果:点击后,格子从点击点向外逐个弹出,而不是整片瞬间消失。

实现只用了一行数学——曼哈顿距离:

unsigned now = GetTickCount();
// 洪泛展开时,延迟 = 到点击点的曼哈顿距离 × 每格延迟
cur.revealStamp = now + (abs(rr - r) + abs(cc - c)) * 10; // ms

渲染时:

int age = (int)(now - cell.revealStamp);
if (age < 0) { DrawHiddenCell(...); return; }   // 波浪还没到,仍显示未翻开
float s = age >= 160 ? 1.0f : 0.55f + 0.45f * age / 160; // 从 55% 放大到 100%
DrawRevealed(..., s);  // 按比例缩放绘制

同理:

16ms 定时器驱动

动画需要持续重绘,但空闲时又不能白烧 CPU。做法是始终挂一个 16ms 定时器,但只在「有动画进行」时才重绘:

case WM_TIMER:
    if (wParam == TIMER_ANIM) {
        if (RenderHasActiveAnimation(g_game))
            InvalidateRect(hwnd, nullptr, FALSE);  // 有动画才刷新
    }
bool RenderHasActiveAnimation(const Game& game) {
    unsigned now = GetTickCount();
    for (const auto& row : game.Board())
        for (const Cell& cell : row) {
            if (cell.revealStamp && (int)(now - cell.revealStamp) < 200) return true;
            if (cell.flagStamp   && (int)(now - cell.flagStamp)   < 200) return true;
            if (cell.mineStamp   && (int)(now - cell.mineStamp)   < 200) return true;
        }
    return false;
}

六、踩过的坑(真实翻车记录)

1. 闪退:棋盘在第一次绘制前是空的

现象:exe 打开一瞬间就闪退。

原因:全局棋盘 g_board 默认是空 vector,而程序启动时只设置了行列数,从没调用 NewGame() 填充棋盘。ShowWindow + UpdateWindow 立刻触发首次 WM_PAINT,按 9×9 循环访问 g_board[r][c] → 越界访问 → 崩溃。

修复:显示窗口前先初始化棋盘(或让构造函数直接填充)。

教训:GUI 程序的初始化顺序要格外小心——第一帧渲染可能在你想不到的时刻发生。

2. 中文注释导致编译失败

现象:源码里写了中文注释和字符串,编译报一堆 C2001、C2864 错误。

原因:源码是 UTF-8,但 MSVC 默认按本机代码页(中文系统是 GBK/936)解析,多字节被错误拆分,进而引发级联的括号/字符串错乱。

修复:给 cl 加 /utf-8,明确告诉编译器「源码是 UTF-8」。

3. 拆分后 UNICODE 宏不一致

现象:把单文件拆成多文件后,render.cpp/dialogs.cpp 报 DrawTextA 无法接收宽字符串。

原因:#define UNICODE 原本只在单文件顶部,拆开后只有 app.cpp 有,其它文件编译成了 ANSI 版本,导致 DrawTextA(窄字符)和 L"..."(宽字符)不匹配。

修复:把 UNICODE/_UNICODE 放到编译参数统一传(/DUNICODE /D_UNICODE),而不是散落在某个源文件里。多文件工程里,全局宏一定要全局定义。

4. 内存对话框模板的对齐陷阱

「自定义难度」对话框不写 .rc 资源文件,而是在内存里手拼 DLGTEMPLATE 结构。

最阴的坑:sizeof(DLGITEMTEMPLATE) 因为结构体尾部对齐会返回 20 字节,但真正的模板格式只要 18 字节。如果直接 memcpy(sizeof(...)),会多写 2 字节把后面的数据顶坏。必须逐字段写入,不能整体拷贝:

// 正确做法:逐字段写
AppendDword(b, style);
AppendDword(b, 0);            // exStyle
AppendWord(b, (WORD)x);       // x
AppendWord(b, (WORD)y);       // y
// ... 而不是 memcpy(&item, sizeof(item))

七、小结

这个项目虽然小,但把 GUI 编程的「完整闭环」都走了一遍:

环节 收获
逻辑设计 状态机 + 洪泛展开 + 首次点击安全
架构拆分 逻辑 / 渲染 / 对话框 / 入口 四层解耦
渲染 双缓冲、渐变、七段数码管、纯 GDI 手绘
动画 时间戳 + 曼哈顿距离延迟 + 定时器按需刷新
工程化 编码、宏、内存布局对齐这些「看不见」的坑

完整的可运行代码和编译说明都在仓库里(game.cpp / render.cpp / dialogs.cpp / app.cpp + build.bat),一行命令即可编译:

cl /O2 /std:c++17 /EHsc /utf-8 /DUNICODE /D_UNICODE ^
   game.cpp render.cpp dialogs.cpp app.cpp ^
   /Fe:minesweeper.exe user32.lib gdi32.lib msimg32.lib

如果你也想动手写一个,我的建议是:先把「能玩」做出来,再逐步加皮肤和动画——逻辑稳定后,换皮肤只是替换 render.cpp 的事。这大概也是「关注点分离」最有说服力的一个小例子。


文中所有代码均为项目真实实现,可在源码中对应查看。

附录:完整源码

项目全部源码,按模块列出;编译命令见正文「小结」一节。

game.h

// game.h — 扫雷核心游戏逻辑(与界面无关)
#pragma once

#include <vector>

enum class GameState { PLAYING, WON, LOST };

struct Cell {
    bool mine     = false;
    bool revealed = false;
    bool flagged  = false;
    int  adj      = 0;   // 周围地雷数

    // 动画时间戳(毫秒,0 表示无动画;由 GetTickCount 设置)
    unsigned revealStamp = 0;   // 翻开弹出动画起点
    unsigned flagStamp   = 0;   // 插旗动画起点
    unsigned mineStamp   = 0;   // 地雷弹出动画起点
};

struct Difficulty {
    int         rows, cols, mines;
    const wchar_t* name;
};

class Game {
public:
    Game();

    // 开局 / 重开
    void NewGame(int rows, int cols, int mines);
    void Restart();

    // 玩家操作
    void Reveal(int r, int c);
    void Chord(int r, int c);
    void ToggleFlag(int r, int c);
    void Tick();               // 计时器走一秒

    // 状态查询
    const std::vector<std::vector<Cell>>& Board() const { return m_board; }
    int       Rows()     const { return m_rows; }
    int       Cols()     const { return m_cols; }
    int       Mines()    const { return m_mines; }
    GameState State()    const { return m_state; }
    int       Time()     const { return m_time; }
    int       DeadRow()  const { return m_deadR; }
    int       DeadCol()  const { return m_deadC; }
    bool      IsRevealed(int r, int c) const;
    int       FlagCount() const;

private:
    void PlaceMines(int safeR, int safeC);
    void Lose(int r, int c);
    void Win();
    bool CheckWin() const;

    std::vector<std::vector<Cell>> m_board;
    int       m_rows, m_cols, m_mines;
    GameState m_state;
    bool      m_minesPlaced;
    int       m_time;
    int       m_deadR, m_deadC;
};

game.cpp

// game.cpp — 扫雷核心游戏逻辑实现
#include "game.h"

#include <windows.h>   // GetTickCount(动画时间戳)
#include <cstdlib>
#include <utility>

namespace {
    constexpr unsigned kWaveStagger = 10;   // 翻开波浪:每格距离延迟(ms)
    constexpr unsigned kMineStagger = 35;   // 失败时地雷依次弹出延迟(ms)
    constexpr unsigned kWinStagger  = 30;   // 胜利插旗依次延迟(ms)
}

Game::Game()
    : m_rows(9), m_cols(9), m_mines(10),
      m_state(GameState::PLAYING), m_minesPlaced(false),
      m_time(0), m_deadR(-1), m_deadC(-1) {
    m_board.assign(m_rows, std::vector<Cell>(m_cols));
}

void Game::NewGame(int rows, int cols, int mines) {
    m_rows  = rows;
    m_cols  = cols;
    m_mines = mines;
    Restart();
}

void Game::Restart() {
    m_board.assign(m_rows, std::vector<Cell>(m_cols));
    m_state       = GameState::PLAYING;
    m_minesPlaced = false;
    m_time        = 0;
    m_deadR = m_deadC = -1;
}

void Game::PlaceMines(int safeR, int safeC) {
    int placed = 0;
    while (placed < m_mines) {
        int r = std::rand() % m_rows;
        int c = std::rand() % m_cols;
        if (m_board[r][c].mine) continue;
        // 首次点击处及其周围 8 格不放雷,保证开局安全
        if (std::abs(r - safeR) <= 1 && std::abs(c - safeC) <= 1) continue;
        m_board[r][c].mine = true;
        ++placed;
    }
    for (int r = 0; r < m_rows; ++r)
        for (int c = 0; c < m_cols; ++c) {
            int n = 0;
            for (int dr = -1; dr <= 1; ++dr)
                for (int dc = -1; dc <= 1; ++dc) {
                    int nr = r + dr, nc = c + dc;
                    if (nr < 0 || nr >= m_rows || nc < 0 || nc >= m_cols) continue;
                    if (m_board[nr][nc].mine) ++n;
                }
            m_board[r][c].adj = n;
        }
}

void Game::Lose(int r, int c) {
    m_state = GameState::LOST;
    m_deadR = r;
    m_deadC = c;
    unsigned now = GetTickCount();
    m_board[r][c].revealed  = true;
    m_board[r][c].mineStamp = now;   // 踩中的雷最先弹出
    for (int rr = 0; rr < m_rows; ++rr)
        for (int cc = 0; cc < m_cols; ++cc)
            if (m_board[rr][cc].mine && !m_board[rr][cc].flagged) {
                m_board[rr][cc].revealed  = true;   // 翻开其余未被标记的雷
                m_board[rr][cc].mineStamp = now + (unsigned)((std::abs(rr - r) + std::abs(cc - c)) * kMineStagger);
            }
}

bool Game::CheckWin() const {
    for (int r = 0; r < m_rows; ++r)
        for (int c = 0; c < m_cols; ++c)
            if (!m_board[r][c].mine && !m_board[r][c].revealed)
                return false;
    return true;
}

void Game::Win() {
    m_state = GameState::WON;
    unsigned now = GetTickCount();
    int seq = 0;
    for (int r = 0; r < m_rows; ++r)
        for (int c = 0; c < m_cols; ++c)
            if (m_board[r][c].mine) {
                m_board[r][c].flagged   = true;   // 胜利自动插满旗
                m_board[r][c].flagStamp = now + (unsigned)(seq * kWinStagger);
                ++seq;
            }
}

void Game::Reveal(int r, int c) {
    if (m_state == GameState::LOST || m_state == GameState::WON) return;
    if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return;
    Cell& cell = m_board[r][c];
    if (cell.flagged || cell.revealed) return;

    if (!m_minesPlaced) {
        PlaceMines(r, c);
        m_minesPlaced = true;
    }

    if (cell.mine) { Lose(r, c); return; }

    // 洪泛展开(按曼哈顿距离设置波浪延迟,形成从点击点扩散的动画)
    unsigned now = GetTickCount();
    std::vector<std::pair<int, int>> stack;
    stack.push_back({r, c});
    while (!stack.empty()) {
        auto [rr, cc] = stack.back();
        stack.pop_back();
        Cell& cur = m_board[rr][cc];
        if (cur.revealed || cur.flagged) continue;
        cur.revealed     = true;
        cur.revealStamp  = now + (unsigned)((std::abs(rr - r) + std::abs(cc - c)) * kWaveStagger);
        if (cur.adj == 0) {
            for (int dr = -1; dr <= 1; ++dr)
                for (int dc = -1; dc <= 1; ++dc) {
                    int nr = rr + dr, nc = cc + dc;
                    if (nr < 0 || nr >= m_rows || nc < 0 || nc >= m_cols) continue;
                    Cell& nb = m_board[nr][nc];
                    if (!nb.revealed && !nb.flagged) stack.push_back({nr, nc});
                }
        }
    }
    if (CheckWin()) Win();
}

void Game::Chord(int r, int c) {
    if (m_state != GameState::PLAYING) return;
    if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return;
    if (!m_board[r][c].revealed) return;

    int flags = 0;
    for (int dr = -1; dr <= 1; ++dr)
        for (int dc = -1; dc <= 1; ++dc) {
            int nr = r + dr, nc = c + dc;
            if (nr < 0 || nr >= m_rows || nc < 0 || nc >= m_cols) continue;
            if (m_board[nr][nc].flagged) ++flags;
        }
    if (flags != m_board[r][c].adj) return;

    for (int dr = -1; dr <= 1; ++dr)
        for (int dc = -1; dc <= 1; ++dc) {
            int nr = r + dr, nc = c + dc;
            if (nr < 0 || nr >= m_rows || nc < 0 || nc >= m_cols) continue;
            if (!m_board[nr][nc].revealed && !m_board[nr][nc].flagged)
                Reveal(nr, nc);
        }
}

void Game::ToggleFlag(int r, int c) {
    if (m_state != GameState::PLAYING) return;
    if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return;
    Cell& cell = m_board[r][c];
    if (cell.revealed) return;
    cell.flagged = !cell.flagged;
    if (cell.flagged) cell.flagStamp = GetTickCount();   // 插旗弹出动画
    else              cell.flagStamp = 0;
}

void Game::Tick() {
    if (m_state == GameState::PLAYING && m_time < 999)
        ++m_time;
}

bool Game::IsRevealed(int r, int c) const {
    return r >= 0 && r < m_rows && c >= 0 && c < m_cols && m_board[r][c].revealed;
}

int Game::FlagCount() const {
    int n = 0;
    for (int r = 0; r < m_rows; ++r)
        for (int c = 0; c < m_cols; ++c)
            if (m_board[r][c].flagged) ++n;
    return n;
}

render.h

// render.h — 棋盘绘制(GDI)
#pragma once

#include <windows.h>
#include "game.h"

// 布局常量(像素)
constexpr int CELL     = 30;
constexpr int HEADER_H = 62;
constexpr int PAD      = 10;
constexpr int GAP      = 8;

// GDI 资源生命周期(窗口创建/销毁时调用)
void RenderInit();
void RenderCleanup();

// 布局辅助
void ComputeClientSize(int rows, int cols, int& width, int& height);
RECT CellRect(int r, int c);
RECT HeaderRect(int cols);
RECT SmileyRect(int cols);
bool BoardHit(POINT pt, int rows, int cols, int& r, int& c);

// 主绘制入口(含双缓冲)
void RenderClient(HDC hdc, const Game& game, bool smileDown, const RECT& client);

// 是否还有动画进行中(用于驱动动画重绘)
bool RenderHasActiveAnimation(const Game& game);

render.cpp

// render.cpp — 棋盘绘制实现(Windows 7 蓝色 Aero 风格 + 动画)
#include "render.h"

#include <cmath>

namespace {

// ---- 配色(Windows 7 蓝色风格)----
const COLORREF kColBgTop    = RGB(206, 228, 248);   // 棋盘背景渐变(上)
const COLORREF kColBgBot    = RGB(147, 190, 230);   // 棋盘背景渐变(下)
const COLORREF kColCellTop  = RGB(255, 255, 255);   // 未翻开格子(上, 高光)
const COLORREF kColCellBot  = RGB(199, 221, 244);   // 未翻开格子(下)
const COLORREF kColOpenTop  = RGB(219, 233, 250);   // 翻开格子(上)
const COLORREF kColOpenBot  = RGB(199, 221, 244);   // 翻开格子(下)
const COLORREF kColPanelTop = RGB(232, 242, 253);   // 顶部面板(上)
const COLORREF kColPanelBot = RGB(181, 210, 238);   // 顶部面板(下)
const COLORREF kColBorder   = RGB(141, 174, 208);   // 格子边框

// ---- 动画时长(ms) ----
constexpr unsigned kPopMs     = 160;   // 翻开/地雷弹出
constexpr unsigned kFlagPopMs = 120;   // 插旗弹出
constexpr unsigned kAnimWin   = 200;   // 判定动画是否结束的窗口

HBRUSH g_brushBlack, g_brushRed, g_brushYellow, g_brushWhite, g_brushBorder;
HPEN   g_penBlack, g_penRed;
HFONT  g_fontNum;

// ---- 渐变 / 缩放辅助 ----
void FillGradientV(HDC hdc, const RECT& rc, COLORREF top, COLORREF bottom) {
    TRIVERTEX v[2];
    v[0].x = rc.left;  v[0].y = rc.top;
    v[0].Red   = GetRValue(top)    << 8;
    v[0].Green = GetGValue(top)    << 8;
    v[0].Blue  = GetBValue(top)    << 8;
    v[0].Alpha = 0x0000;
    v[1].x = rc.right; v[1].y = rc.bottom;
    v[1].Red   = GetRValue(bottom) << 8;
    v[1].Green = GetGValue(bottom) << 8;
    v[1].Blue  = GetBValue(bottom) << 8;
    v[1].Alpha = 0x0000;
    GRADIENT_RECT gr = { 0, 1 };
    GradientFill(hdc, v, 2, &gr, 1, GRADIENT_FILL_RECT_V);
}

RECT ScaleRect(const RECT& rc, float s) {
    int dw = (int)((rc.right - rc.left) * (1.0f - s) * 0.5f);
    int dh = (int)((rc.bottom - rc.top) * (1.0f - s) * 0.5f);
    RECT r;
    r.left   = rc.left + dw;
    r.top    = rc.top + dh;
    r.right  = rc.right - dw;
    r.bottom = rc.bottom - dh;
    return r;
}

// ---- 七段数码管 ----
enum Seg {
    SEG_A = 1 << 0, SEG_B = 1 << 1, SEG_C = 1 << 2, SEG_D = 1 << 3,
    SEG_E = 1 << 4, SEG_F = 1 << 5, SEG_G = 1 << 6
};

const BYTE g_segDigits[10] = {
    SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F,              // 0
    SEG_B | SEG_C,                                              // 1
    SEG_A | SEG_B | SEG_G | SEG_E | SEG_D,                      // 2
    SEG_A | SEG_B | SEG_G | SEG_C | SEG_D,                      // 3
    SEG_F | SEG_G | SEG_B | SEG_C,                              // 4
    SEG_A | SEG_F | SEG_G | SEG_C | SEG_D,                      // 5
    SEG_A | SEG_F | SEG_G | SEG_E | SEG_C | SEG_D,              // 6
    SEG_A | SEG_B | SEG_C,                                      // 7
    SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G,      // 8
    SEG_A | SEG_B | SEG_C | SEG_D | SEG_F | SEG_G,              // 9
};

void SegRect(HDC hdc, int x, int y, int w, int h) {
    RECT r = { x, y, x + w, y + h };
    FillRect(hdc, &r, g_brushRed);
}

void DrawDigit7(HDC hdc, int x, int y, int w, int h, int digit) {
    int t   = w >= 20 ? 4 : 3;
    int mid = h / 2;
    BYTE m  = g_segDigits[digit];
    if (m & SEG_A) SegRect(hdc, x + t, y,             w - 2 * t, t);
    if (m & SEG_F) SegRect(hdc, x,     y + t,         t,          mid - t);
    if (m & SEG_B) SegRect(hdc, x + w - t, y + t,     t,          mid - t);
    if (m & SEG_G) SegRect(hdc, x + t, y + mid - t / 2, w - 2 * t, t);
    if (m & SEG_E) SegRect(hdc, x,     y + mid,       t,          h - mid - t);
    if (m & SEG_C) SegRect(hdc, x + w - t, y + mid,   t,          h - mid - t);
    if (m & SEG_D) SegRect(hdc, x + t, y + h - t,     w - 2 * t, t);
}

void DrawMinus7(HDC hdc, int x, int y, int w, int h) {
    int t = w >= 20 ? 4 : 3;
    SegRect(hdc, x + t, y + h / 2 - t / 2, w - 2 * t, t);
}

// ---- 元素绘制 ----
void DrawHiddenCell(HDC hdc, RECT rc) {
    FillGradientV(hdc, rc, kColCellTop, kColCellBot);
    FrameRect(hdc, &rc, g_brushBorder);
}

void DrawFlag(HDC hdc, RECT rc, float s) {
    RECT r = ScaleRect(rc, s);
    int cx = (r.left + r.right) / 2;
    int top = r.top + 5, bot = r.bottom - 5;
    HGDIOBJ old = SelectObject(hdc, g_penBlack);
    MoveToEx(hdc, cx, top, nullptr); LineTo(hdc, cx, bot);
    MoveToEx(hdc, cx - 5, bot - 1, nullptr); LineTo(hdc, cx + 5, bot - 1);
    SelectObject(hdc, old);
    HGDIOBJ ob = SelectObject(hdc, g_brushRed);
    HGDIOBJ op = SelectObject(hdc, GetStockObject(NULL_PEN));
    POINT tri[3] = { {cx + 1, top}, {cx + 1, top + 8}, {cx - 6, top + 4} };
    Polygon(hdc, tri, 3);
    SelectObject(hdc, ob);
    SelectObject(hdc, op);
}

void DrawWrongFlag(HDC hdc, RECT rc) {
    HGDIOBJ old = SelectObject(hdc, g_penRed);
    MoveToEx(hdc, rc.left + 6, rc.top + 6, nullptr);
    LineTo(hdc, rc.right - 6, rc.bottom - 6);
    MoveToEx(hdc, rc.right - 6, rc.top + 6, nullptr);
    LineTo(hdc, rc.left + 6, rc.bottom - 6);
    SelectObject(hdc, old);
}

void DrawMine(HDC hdc, RECT rc, bool red, float s) {
    int cx = (rc.left + rc.right) / 2, cy = (rc.top + rc.bottom) / 2;
    int R = (int)(7.0f * s);
    if (R < 1) return;
    HGDIOBJ ob = SelectObject(hdc, red ? g_brushRed : g_brushBlack);
    HGDIOBJ op = SelectObject(hdc, GetStockObject(NULL_PEN));
    Ellipse(hdc, cx - R, cy - R, cx + R, cy + R);
    SelectObject(hdc, ob);
    if (!red) {
        // 高光(光泽球体)
        int hl = R > 3 ? R / 3 : 1;
        HGDIOBJ ow = SelectObject(hdc, g_brushWhite);
        Ellipse(hdc, cx - R + hl, cy - R + hl, cx - hl, cy - hl);
        SelectObject(hdc, ow);
    }
    SelectObject(hdc, op);
}

void DrawNumber(HDC hdc, RECT rc, int n) {
    static const COLORREF colors[9] = {
        0,
        RGB(0, 0, 255),     // 1 蓝
        RGB(0, 128, 0),     // 2 绿
        RGB(255, 0, 0),     // 3 红
        RGB(0, 0, 128),     // 4 深蓝
        RGB(128, 0, 0),     // 5 深红
        RGB(0, 128, 128),   // 6 青
        RGB(0, 0, 0),       // 7 黑
        RGB(128, 128, 128)  // 8 灰
    };
    SetTextColor(hdc, colors[n]);
    SetBkMode(hdc, TRANSPARENT);
    HGDIOBJ old = SelectObject(hdc, g_fontNum);
    wchar_t s[2] = { (wchar_t)(L'0' + n), 0 };
    DrawText(hdc, s, 1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
    SelectObject(hdc, old);
}

void DrawCounter(HDC hdc, RECT rc, int value) {
    int v = value < -99 ? -99 : (value > 999 ? 999 : value);
    FillRect(hdc, &rc, g_brushBlack);
    FrameRect(hdc, &rc, g_brushBorder);

    int digitW = 19, digitH = 34;
    int totalW = 3 * digitW;
    int x0 = rc.left + ((rc.right - rc.left) - totalW) / 2;
    int y0 = rc.top + (rc.bottom - rc.top - digitH) / 2;

    int absv = v < 0 ? -v : v;
    int hundreds = absv / 100 % 10;
    int tens     = absv / 10 % 10;
    int ones     = absv % 10;

    if (v < 0) {
        DrawMinus7(hdc, x0, y0, digitW, digitH);
        DrawDigit7(hdc, x0 + digitW, y0, digitW, digitH, tens);
        DrawDigit7(hdc, x0 + 2 * digitW, y0, digitW, digitH, ones);
    } else {
        DrawDigit7(hdc, x0, y0, digitW, digitH, hundreds);
        DrawDigit7(hdc, x0 + digitW, y0, digitW, digitH, tens);
        DrawDigit7(hdc, x0 + 2 * digitW, y0, digitW, digitH, ones);
    }
}

void DrawSmiley(HDC hdc, RECT rc, GameState state, bool down) {
    // 蓝色光泽按钮
    FillGradientV(hdc, rc, down ? kColCellBot : kColCellTop, down ? kColCellTop : kColCellBot);
    FrameRect(hdc, &rc, g_brushBorder);

    int cx = (rc.left + rc.right) / 2, cy = (rc.top + rc.bottom) / 2;
    HGDIOBJ ob = SelectObject(hdc, g_brushYellow);
    HGDIOBJ op = SelectObject(hdc, g_penBlack);
    Ellipse(hdc, cx - 11, cy - 11, cx + 11, cy + 11);
    // 高光
    SelectObject(hdc, g_brushWhite);
    SelectObject(hdc, GetStockObject(NULL_PEN));
    Ellipse(hdc, cx - 7, cy - 9, cx - 1, cy - 4);
    SelectObject(hdc, g_penBlack);

    if (state == GameState::LOST) {
        MoveToEx(hdc, cx - 7, cy - 5, nullptr); LineTo(hdc, cx - 3, cy - 1);
        MoveToEx(hdc, cx - 3, cy - 5, nullptr); LineTo(hdc, cx - 7, cy - 1);
        MoveToEx(hdc, cx + 3, cy - 5, nullptr); LineTo(hdc, cx + 7, cy - 1);
        MoveToEx(hdc, cx + 7, cy - 5, nullptr); LineTo(hdc, cx + 3, cy - 1);
        Arc(hdc, cx - 6, cy + 3, cx + 6, cy + 11, cx - 6, cy + 7, cx + 6, cy + 7);
    } else if (state == GameState::WON) {
        HGDIOBJ ob2 = SelectObject(hdc, g_brushBlack);
        HGDIOBJ op2 = SelectObject(hdc, GetStockObject(NULL_PEN));
        Rectangle(hdc, cx - 8, cy - 5, cx - 1, cy + 1);
        Rectangle(hdc, cx + 1, cy - 5, cx + 8, cy + 1);
        SelectObject(hdc, ob2);
        SelectObject(hdc, op2);
        MoveToEx(hdc, cx - 1, cy - 2, nullptr); LineTo(hdc, cx + 1, cy - 2);
        Arc(hdc, cx - 6, cy + 1, cx + 6, cy + 9, cx + 6, cy + 5, cx - 6, cy + 5);
    } else {
        HGDIOBJ ob2 = SelectObject(hdc, g_brushBlack);
        HGDIOBJ op2 = SelectObject(hdc, GetStockObject(NULL_PEN));
        Ellipse(hdc, cx - 6, cy - 4, cx - 3, cy - 1);
        Ellipse(hdc, cx + 3, cy - 4, cx + 6, cy - 1);
        SelectObject(hdc, ob2);
        SelectObject(hdc, op2);
        Arc(hdc, cx - 6, cy + 1, cx + 6, cy + 9, cx + 6, cy + 5, cx - 6, cy + 5);
    }
    SelectObject(hdc, op);
}

void DrawRevealed(HDC hdc, RECT rc, const Cell& cell, const Game& game, int r, int c, float s, unsigned now) {
    RECT inner = ScaleRect(rc, s);
    FillGradientV(hdc, inner, kColOpenTop, kColOpenBot);
    FrameRect(hdc, &inner, g_brushBorder);

    if (cell.mine) {
        float ms = 1.0f;
        if (cell.mineStamp) {
            int age = (int)(now - cell.mineStamp);
            if (age < 0) ms = 0.0f;
            else if (age < (int)kPopMs) ms = 0.4f + 0.6f * age / (float)kPopMs;
        }
        DrawMine(hdc, inner, (r == game.DeadRow() && c == game.DeadCol()), ms);
    } else if (cell.adj > 0) {
        if (s >= 0.8f) DrawNumber(hdc, inner, cell.adj);   // 弹出接近完成再显示数字
    }
}

void DrawCell(HDC hdc, int r, int c, const Game& game) {
    RECT rc = CellRect(r, c);
    const Cell& cell = game.Board()[r][c];
    unsigned now = GetTickCount();

    if (!cell.revealed) {
        DrawHiddenCell(hdc, rc);
        if (cell.flagged) {
            float s = 1.0f;
            if (cell.flagStamp) {
                int age = (int)(now - cell.flagStamp);
                if (age < (int)kFlagPopMs) s = 0.6f + 0.4f * (age < 0 ? 0 : age) / (float)kFlagPopMs;
            }
            DrawFlag(hdc, rc, s);
        }
        if (game.State() == GameState::LOST && cell.flagged && !cell.mine)
            DrawWrongFlag(hdc, rc);   // 标错的旗子打叉
    } else {
        if (cell.revealStamp) {
            int age = (int)(now - cell.revealStamp);
            if (age < 0) { DrawHiddenCell(hdc, rc); return; }   // 波浪尚未到达
            float s = (age >= (int)kPopMs) ? 1.0f : (0.55f + 0.45f * age / (float)kPopMs);
            DrawRevealed(hdc, rc, cell, game, r, c, s, now);
        } else {
            DrawRevealed(hdc, rc, cell, game, r, c, 1.0f, now);
        }
    }
}

void DrawHeader(HDC hdc, const Game& game, bool smileDown) {
    RECT panel = HeaderRect(game.Cols());
    FillGradientV(hdc, panel, kColPanelTop, kColPanelBot);
    FrameRect(hdc, &panel, g_brushBorder);

    RECT lc = { panel.left + 6, panel.top + 8, panel.left + 68, panel.top + 50 };
    DrawCounter(hdc, lc, game.Mines() - game.FlagCount());

    DrawSmiley(hdc, SmileyRect(game.Cols()), game.State(), smileDown);

    RECT rc = { panel.right - 68, panel.top + 8, panel.right - 6, panel.top + 50 };
    DrawCounter(hdc, rc, game.Time());
}

void DrawGame(HDC hdc, const RECT& client, const Game& game, bool smileDown) {
    FillGradientV(hdc, client, kColBgTop, kColBgBot);
    DrawHeader(hdc, game, smileDown);
    for (int r = 0; r < game.Rows(); ++r)
        for (int c = 0; c < game.Cols(); ++c)
            DrawCell(hdc, r, c, game);
}

} // namespace

void RenderInit() {
    g_brushBlack  = CreateSolidBrush(RGB(0, 0, 0));
    g_brushRed    = CreateSolidBrush(RGB(255, 0, 0));
    g_brushYellow = CreateSolidBrush(RGB(255, 255, 0));
    g_brushWhite  = CreateSolidBrush(RGB(255, 255, 255));
    g_brushBorder = CreateSolidBrush(kColBorder);
    g_penBlack    = CreatePen(PS_SOLID, 1, RGB(0, 0, 0));
    g_penRed      = CreatePen(PS_SOLID, 2, RGB(255, 0, 0));
    g_fontNum     = CreateFont(-20, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
                               DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
                               ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Tahoma");
}

void RenderCleanup() {
    DeleteObject(g_brushBlack);
    DeleteObject(g_brushRed);
    DeleteObject(g_brushYellow);
    DeleteObject(g_brushWhite);
    DeleteObject(g_brushBorder);
    DeleteObject(g_penBlack);
    DeleteObject(g_penRed);
    DeleteObject(g_fontNum);
}

void ComputeClientSize(int rows, int cols, int& width, int& height) {
    width  = 2 * PAD + cols * CELL;
    height = 2 * PAD + HEADER_H + GAP + rows * CELL;
}

RECT CellRect(int r, int c) {
    RECT rc;
    rc.left   = PAD + c * CELL;
    rc.top    = PAD + HEADER_H + GAP + r * CELL;
    rc.right  = rc.left + CELL;
    rc.bottom = rc.top + CELL;
    return rc;
}

RECT HeaderRect(int cols) {
    RECT rc = { PAD, PAD, PAD + cols * CELL, PAD + HEADER_H };
    return rc;
}

RECT SmileyRect(int cols) {
    RECT rc;
    int cx = PAD + cols * CELL / 2;
    rc.left   = cx - 21;
    rc.top    = PAD + 8;
    rc.right  = cx + 21;
    rc.bottom = PAD + 8 + 42;
    return rc;
}

bool BoardHit(POINT pt, int rows, int cols, int& r, int& c) {
    int bx = PAD, by = PAD + HEADER_H + GAP;
    if (pt.x < bx || pt.y < by) return false;
    int cc = (pt.x - bx) / CELL;
    int rr = (pt.y - by) / CELL;
    if (rr < 0 || rr >= rows || cc < 0 || cc >= cols) return false;
    r = rr;
    c = cc;
    return true;
}

bool RenderHasActiveAnimation(const Game& game) {
    unsigned now = GetTickCount();
    const auto& board = game.Board();
    for (const auto& row : board)
        for (const Cell& cell : row) {
            if (cell.revealStamp && (int)(now - cell.revealStamp) < (int)kAnimWin) return true;
            if (cell.flagStamp   && (int)(now - cell.flagStamp)   < (int)kAnimWin) return true;
            if (cell.mineStamp   && (int)(now - cell.mineStamp)   < (int)kAnimWin) return true;
        }
    return false;
}

void RenderClient(HDC hdc, const Game& game, bool smileDown, const RECT& client) {
    int w = client.right - client.left;
    int h = client.bottom - client.top;
    HDC mem = CreateCompatibleDC(hdc);
    HBITMAP bmp = CreateCompatibleBitmap(hdc, w, h);
    HBITMAP oldb = (HBITMAP)SelectObject(mem, bmp);
    DrawGame(mem, client, game, smileDown);
    BitBlt(hdc, 0, 0, w, h, mem, 0, 0, SRCCOPY);
    SelectObject(mem, oldb);
    DeleteObject(bmp);
    DeleteDC(mem);
}

dialogs.h

// dialogs.h — 自定义难度对话框
#pragma once

#include <windows.h>

// 显示"自定义难度"对话框;返回 true 表示确定,并回填新的行列雷数
bool ShowCustomDialog(HWND parent, int& rows, int& cols, int& mines);

dialogs.cpp

// dialogs.cpp — 自定义难度对话框实现(内存对话框模板,无需 .rc 资源)
#include "dialogs.h"

#include <vector>

namespace {

constexpr int IDC_ROWS  = 1001;
constexpr int IDC_COLS  = 1002;
constexpr int IDC_MINES = 1003;

struct DlgData {
    int rows, cols, mines;
};

void AppendWord(std::vector<BYTE>& b, WORD w) {
    b.push_back((BYTE)(w & 0xFF));
    b.push_back((BYTE)(w >> 8));
}
void AppendDword(std::vector<BYTE>& b, DWORD d) {
    AppendWord(b, (WORD)(d & 0xFFFF));
    AppendWord(b, (WORD)(d >> 16));
}
void AppendWStr(std::vector<BYTE>& b, const wchar_t* s) {
    for (; *s; ++s) AppendWord(b, (WORD)*s);
    AppendWord(b, 0);
}
void Align4(std::vector<BYTE>& b) {
    while (b.size() % 4) b.push_back(0);
}

std::vector<BYTE> BuildTemplate() {
    std::vector<BYTE> b;

    // DLGTEMPLATE 头部(逐字段写入,避免结构体对齐填充问题)
    AppendDword(b, WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_SETFONT | DS_CENTER); // style
    AppendDword(b, 0);              // dwExtendedStyle
    AppendWord(b, 8);               // cdit = 3 标签 + 3 编辑框 + 2 按钮
    AppendWord(b, 0);               // x
    AppendWord(b, 0);               // y
    AppendWord(b, 160);             // cx
    AppendWord(b, 82);              // cy
    AppendWord(b, 0);               // menu
    AppendWord(b, 0);               // windowClass
    AppendWStr(b, L"自定义难度");    // title
    AppendWord(b, 9);               // 字号
    AppendWStr(b, L"MS Shell Dlg"); // 字体名

    auto AddItem = [&](WORD id, WORD cls, const wchar_t* text,
                       DWORD style, int x, int y, int cx, int cy) {
        Align4(b);
        AppendDword(b, style);
        AppendDword(b, 0);          // exStyle
        AppendWord(b, (WORD)x);
        AppendWord(b, (WORD)y);
        AppendWord(b, (WORD)cx);
        AppendWord(b, (WORD)cy);
        AppendWord(b, id);
        AppendWord(b, 0xFFFF);
        AppendWord(b, cls);         // 控件类(序号)
        if (text) AppendWStr(b, text);
        else       AppendWord(b, 0);
        AppendWord(b, 0);           // 附加创建数据长度
    };

    // 标签
    AddItem(0, 0x0082, L"行数:",    WS_CHILD | WS_VISIBLE, 12, 14, 40, 12);
    AddItem(0, 0x0082, L"列数:",    WS_CHILD | WS_VISIBLE, 12, 30, 40, 12);
    AddItem(0, 0x0082, L"地雷数:",  WS_CHILD | WS_VISIBLE, 12, 46, 40, 12);
    // 编辑框
    AddItem(IDC_ROWS,  0x0081, nullptr, WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_NUMBER, 56, 12, 65, 12);
    AddItem(IDC_COLS,  0x0081, nullptr, WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_NUMBER, 56, 28, 65, 12);
    AddItem(IDC_MINES, 0x0081, nullptr, WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_NUMBER, 56, 44, 65, 12);
    // 按钮
    AddItem(IDOK,     0x0080, L"确定", WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON, 28, 62, 45, 14);
    AddItem(IDCANCEL, 0x0080, L"取消", WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 88, 62, 45, 14);

    return b;
}

INT_PTR CALLBACK DlgProc(HWND h, UINT m, WPARAM w, LPARAM l) {
    switch (m) {
    case WM_INITDIALOG: {
        DlgData* d = reinterpret_cast<DlgData*>(l);
        SetWindowLongPtr(h, DWLP_USER, reinterpret_cast<LONG_PTR>(d));
        wchar_t buf[16];
        wsprintf(buf, L"%d", d->rows);  SetDlgItemText(h, IDC_ROWS, buf);
        wsprintf(buf, L"%d", d->cols);  SetDlgItemText(h, IDC_COLS, buf);
        wsprintf(buf, L"%d", d->mines); SetDlgItemText(h, IDC_MINES, buf);
        return TRUE;
    }
    case WM_COMMAND:
        switch (LOWORD(w)) {
        case IDOK: {
            DlgData* d = reinterpret_cast<DlgData*>(GetWindowLongPtr(h, DWLP_USER));
            int r  = (int)GetDlgItemInt(h, IDC_ROWS, nullptr, FALSE);
            int c  = (int)GetDlgItemInt(h, IDC_COLS, nullptr, FALSE);
            int mn = (int)GetDlgItemInt(h, IDC_MINES, nullptr, FALSE);
            if (r < 5 || r > 40 || c < 5 || c > 60) {
                MessageBox(h, L"行数范围 5–40,列数范围 5–60。", L"输入有误", MB_ICONWARNING);
                return TRUE;
            }
            int maxm = r * c - 1;
            if (mn < 1 || mn > maxm) {
                wchar_t msg[128];
                wsprintf(msg, L"地雷数量需在 1–%d 之间。", maxm);
                MessageBox(h, msg, L"输入有误", MB_ICONWARNING);
                return TRUE;
            }
            d->rows = r;
            d->cols = c;
            d->mines = mn;
            EndDialog(h, IDOK);
            return TRUE;
        }
        case IDCANCEL:
            EndDialog(h, IDCANCEL);
            return TRUE;
        }
    }
    return FALSE;
}

} // namespace

bool ShowCustomDialog(HWND parent, int& rows, int& cols, int& mines) {
    DlgData data = { rows, cols, mines };
    std::vector<BYTE> tmpl = BuildTemplate();
    INT_PTR res = DialogBoxIndirectParam(
        GetModuleHandle(nullptr),
        reinterpret_cast<const DLGTEMPLATE*>(tmpl.data()),
        parent, DlgProc, reinterpret_cast<LPARAM>(&data));
    if (res == IDOK) {
        rows  = data.rows;
        cols  = data.cols;
        mines = data.mines;
        return true;
    }
    return false;
}

app.cpp

// app.cpp — 程序入口、窗口与消息处理
// 注:UNICODE/_UNICODE 由编译参数统一定义(见 build.bat)
#define WIN32_LEAN_AND_MEAN

#include <windows.h>
#include <windowsx.h>
#include <cstdlib>
#include <ctime>

#include "game.h"
#include "render.h"
#include "dialogs.h"

namespace {

constexpr int TIMER_ID   = 1;   // 1 秒计时
constexpr int TIMER_ANIM = 2;   // 动画驱动(约 60fps)

constexpr int IDM_NEW    = 100;
constexpr int IDM_BEGIN  = 101;
constexpr int IDM_INTER  = 102;
constexpr int IDM_EXPERT = 103;
constexpr int IDM_CUSTOM = 104;
constexpr int IDM_ABOUT  = 105;

const DWORD kStyle = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX;

HINSTANCE g_hInst;
HWND      g_hwnd;
Game      g_game;
bool      g_smileDown   = false;
int       g_customRows  = 16;
int       g_customCols  = 16;
int       g_customMines = 40;

const Difficulty g_presets[] = {
    { 9,  9, 10, L"初级" },
    { 16, 16, 40, L"中级" },
    { 16, 30, 99, L"高级" },
};

LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);

// 依据当前棋盘尺寸调整窗口大小并触发重绘
void ResetWindowAndInvalidate() {
    int cw, ch;
    ComputeClientSize(g_game.Rows(), g_game.Cols(), cw, ch);
    RECT rc = { 0, 0, cw, ch };
    AdjustWindowRectEx(&rc, kStyle, TRUE, 0);
    SetWindowPos(g_hwnd, nullptr, 0, 0, rc.right - rc.left, rc.bottom - rc.top,
                 SWP_NOMOVE | SWP_NOZORDER);
    InvalidateRect(g_hwnd, nullptr, TRUE);
}

void NewGame() {
    g_game.Restart();
    ResetWindowAndInvalidate();
}

void SetDifficulty(int idx) {
    const Difficulty& d = (idx < 3)
        ? g_presets[idx]
        : Difficulty{ g_customRows, g_customCols, g_customMines, L"自定义" };
    g_game.NewGame(d.rows, d.cols, d.mines);
    wchar_t title[64];
    wsprintf(title, L"扫雷 - %s (%d×%d, %d 雷)", d.name, d.rows, d.cols, d.mines);
    SetWindowText(g_hwnd, title);
    ResetWindowAndInvalidate();
}

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    switch (msg) {
    case WM_CREATE: {
        RenderInit();

        // 菜单
        HMENU menu = CreateMenu();
        HMENU game = CreatePopupMenu();
        AppendMenu(game, MF_STRING, IDM_NEW,    L"新游戏(&N)\tF2");
        AppendMenu(game, MF_SEPARATOR, 0, nullptr);
        AppendMenu(game, MF_STRING, IDM_BEGIN,  L"初级(&B)");
        AppendMenu(game, MF_STRING, IDM_INTER,  L"中级(&I)");
        AppendMenu(game, MF_STRING, IDM_EXPERT, L"高级(&E)");
        AppendMenu(game, MF_STRING, IDM_CUSTOM, L"自定义(&C)...");
        AppendMenu(menu, MF_POPUP, (UINT_PTR)game, L"游戏(&G)");
        HMENU help = CreatePopupMenu();
        AppendMenu(help, MF_STRING, IDM_ABOUT, L"关于(&A)...");
        AppendMenu(menu, MF_POPUP, (UINT_PTR)help, L"帮助(&H)");
        SetMenu(hwnd, menu);

        SetTimer(hwnd, TIMER_ID, 1000, nullptr);
        SetTimer(hwnd, TIMER_ANIM, 16, nullptr);
        return 0;
    }

    case WM_TIMER:
        if (wParam == TIMER_ID) {
            g_game.Tick();
            if (g_game.State() == GameState::PLAYING) {
                RECT rc = HeaderRect(g_game.Cols());
                InvalidateRect(hwnd, &rc, FALSE);
            }
        } else if (wParam == TIMER_ANIM) {
            if (RenderHasActiveAnimation(g_game))
                InvalidateRect(hwnd, nullptr, FALSE);
        }
        return 0;

    case WM_PAINT: {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hwnd, &ps);
        RECT client;
        GetClientRect(hwnd, &client);
        RenderClient(hdc, g_game, g_smileDown, client);
        EndPaint(hwnd, &ps);
        return 0;
    }

    case WM_PRINTCLIENT: {
        RECT client;
        GetClientRect(hwnd, &client);
        RenderClient((HDC)wParam, g_game, g_smileDown, client);
        return 0;
    }

    case WM_LBUTTONDOWN: {
        SetFocus(hwnd);
        POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
        RECT sr = SmileyRect(g_game.Cols());
        if (PtInRect(&sr, pt)) {
            g_smileDown = true;
            SetCapture(hwnd);
            InvalidateRect(hwnd, &sr, FALSE);
            return 0;
        }
        int r, c;
        if (BoardHit(pt, g_game.Rows(), g_game.Cols(), r, c)) {
            if ((GetKeyState(VK_RBUTTON) & 0x8000) && g_game.IsRevealed(r, c))
                g_game.Chord(r, c);
            else
                g_game.Reveal(r, c);
            InvalidateRect(hwnd, nullptr, FALSE);
        }
        return 0;
    }

    case WM_LBUTTONUP: {
        if (g_smileDown) {
            g_smileDown = false;
            ReleaseCapture();
            POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
            RECT sr = SmileyRect(g_game.Cols());
            if (PtInRect(&sr, pt)) NewGame();
            else InvalidateRect(hwnd, &sr, FALSE);
        }
        return 0;
    }

    case WM_CAPTURECHANGED:
        g_smileDown = false;
        return 0;

    case WM_RBUTTONDOWN: {
        POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
        int r, c;
        if (BoardHit(pt, g_game.Rows(), g_game.Cols(), r, c)) {
            if ((GetKeyState(VK_LBUTTON) & 0x8000) && g_game.IsRevealed(r, c))
                g_game.Chord(r, c);
            else
                g_game.ToggleFlag(r, c);
            InvalidateRect(hwnd, nullptr, FALSE);
        }
        return 0;
    }

    case WM_KEYDOWN:
        if (wParam == VK_F2) NewGame();
        return 0;

    case WM_COMMAND:
        switch (LOWORD(wParam)) {
        case IDM_NEW:    NewGame(); break;
        case IDM_BEGIN:  SetDifficulty(0); break;
        case IDM_INTER:  SetDifficulty(1); break;
        case IDM_EXPERT: SetDifficulty(2); break;
        case IDM_CUSTOM:
            if (ShowCustomDialog(hwnd, g_customRows, g_customCols, g_customMines))
                SetDifficulty(3);
            break;
        case IDM_ABOUT:
            MessageBox(hwnd,
                       L"扫雷 Minesweeper\n\nC++ / Win32 实现\n\n"
                       L"左键翻开,右键插旗,\n"
                       L"在数字上左右同按可快速翻开。",
                       L"关于扫雷", MB_OK | MB_ICONINFORMATION);
            break;
        }
        return 0;

    case WM_DESTROY:
        KillTimer(hwnd, TIMER_ID);
        KillTimer(hwnd, TIMER_ANIM);
        RenderCleanup();
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProc(hwnd, msg, wParam, lParam);
}

} // namespace

int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow) {
    g_hInst = hInst;
    std::srand((unsigned)std::time(nullptr));

    WNDCLASSW wc = {};
    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInst;
    wc.hCursor       = LoadCursor(nullptr, IDC_ARROW);
    wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
    wc.lpszClassName = L"MinesweeperWnd";
    if (!RegisterClassW(&wc)) return 1;

    // 初始大小(初级 9×9)
    int cw, ch;
    ComputeClientSize(g_game.Rows(), g_game.Cols(), cw, ch);
    RECT rc = { 0, 0, cw, ch };
    AdjustWindowRectEx(&rc, kStyle, TRUE, 0);

    g_hwnd = CreateWindowExW(0, L"MinesweeperWnd", L"扫雷 - 初级 (9×9, 10 雷)",
                             kStyle, CW_USEDEFAULT, CW_USEDEFAULT,
                             rc.right - rc.left, rc.bottom - rc.top,
                             nullptr, nullptr, hInst, nullptr);
    if (!g_hwnd) return 1;

    ShowWindow(g_hwnd, nCmdShow);
    UpdateWindow(g_hwnd);

    MSG msg;
    while (GetMessage(&msg, nullptr, 0, 0) > 0) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return (int)msg.wParam;
}

build.bat

@echo off
rem Build Minesweeper (MSVC toolchain)
call "D:\VS2026\VC\Auxiliary\Build\vcvars64.bat"
cl /nologo /O2 /std:c++17 /EHsc /W3 /utf-8 /DUNICODE /D_UNICODE game.cpp render.cpp dialogs.cpp app.cpp /Fe:minesweeper.exe user32.lib gdi32.lib msimg32.lib
阅读进度 0%