用Vibe-Coding从零实现俄罗斯方块

用 Vibe Coding 从零实现一个俄罗斯方块:C + raylib + GoogleTest 全流程实战

    • [一、为什么再用 Vibe Coding 做一遍俄罗斯方块?](#一、为什么再用 Vibe Coding 做一遍俄罗斯方块?)
    • [二、技术选型:为什么换掉了 VS2022 + gtest?](#二、技术选型:为什么换掉了 VS2022 + gtest?)
    • 三、项目目录与架构总览
      • [3.1 目录结构](#3.1 目录结构)
      • [3.2 架构图(Mermaid)](#3.2 架构图(Mermaid))
      • [3.3 模块职责](#3.3 模块职责)
    • [四、11 个 Phase 的实施与"碰撞拉扯"](#四、11 个 Phase 的实施与"碰撞拉扯")
      • [4.1 Phase 1:脚手架 + 循环队列](#4.1 Phase 1:脚手架 + 循环队列)
      • [4.2 Phase 2:消行算法的"碰撞拉扯" ⭐](#4.2 Phase 2:消行算法的"碰撞拉扯" ⭐)
      • [4.3 Phase 4:7-bag 与"1000 轮验证"](#4.3 Phase 4:7-bag 与"1000 轮验证")
      • [4.4 Phase 5:Ghost 与 Next 预览的视觉调整](#4.4 Phase 5:Ghost 与 Next 预览的视觉调整)
      • [4.5 Phase 7:DAS(Delayed Auto Shift)输入机制](#4.5 Phase 7:DAS(Delayed Auto Shift)输入机制)
      • [4.6 Phase 8:NES 计分与速度曲线](#4.6 Phase 8:NES 计分与速度曲线)
    • [五、构建系统:Makefile + 一键 CI + 时间戳日志](#五、构建系统:Makefile + 一键 CI + 时间戳日志)
      • [5.1 Makefile 目标全景](#5.1 Makefile 目标全景)
      • [5.2 时间戳化日志(`scripts/build_log.ps1`)](#5.2 时间戳化日志(scripts/build_log.ps1))
    • 六、双测试框架的工程价值
      • [6.1 为什么要两套?](#6.1 为什么要两套?)
      • [6.2 gtest 版的写法](#6.2 gtest 版的写法)
    • [七、C/C++ 混编链接陷阱 ⭐](#七、C/C++ 混编链接陷阱 ⭐)
    • [八、音效自合成:纯 Python 标准库生成 8-bit wav](#八、音效自合成:纯 Python 标准库生成 8-bit wav)
      • [8.1 合成原语](#8.1 合成原语)
      • [8.2 每个事件的"音色配方"](#8.2 每个事件的"音色配方")
      • [8.3 撞击声示例](#8.3 撞击声示例)
    • 九、验收与最终结果
      • [9.1 PDF 15 条验收标准](#9.1 PDF 15 条验收标准)
      • [9.2 一键 CI 输出](#9.2 一键 CI 输出)
    • [十、Vibe Coding 的三条经验](#十、Vibe Coding 的三条经验)
    • 参考资料

本文记录用 opencode 作为 AI 代理工具,严格遵循 PDF《Vibe Coding 实现俄罗斯方块》

11 个 Phase 验收曲线,从需求访谈到双测试框架 + 构建日志系统 + 自合成 8-bit 音效,

一次性落地一个 可玩、可测、可维护 的单机俄罗斯方块 PC 客户端的完整工程过程。

适合人群:对 Vibe Coding 工程化感兴趣的开发者、C 语言学习者、想看 raylib + GoogleTest 在 MinGW 下怎么玩的人。

一、为什么再用 Vibe Coding 做一遍俄罗斯方块?

俄罗斯方块是经典的"小而全"项目:

  • 数据结构密度高:循环队列(7-bag)、二维数组(棋盘)、状态机(游戏控制器)
  • 算法点足够:消行压缩、Fisher--Yates 洗牌、DAS 自动重复、NES 计分与速度曲线
  • UI 与逻辑可清晰分离:逻辑层零依赖,可纯单元测试;UI 层薄、靠人眼验收

PDF 给出的提示词约束[1](#1)非常工程化,关键有三条:

  1. 不要直接写代码,先做 Socratic 提问法 的深度访谈
  2. 每 Phase 完成后人工 review 代码与测试用例,跟 AI "碰撞拉扯"
  3. UI Phase 手动验收,逻辑 Phase 跑 gtest 验收后再进下一 Phase

这套流程本质是把 AI 当"高速打字员 + 第一稿思考者",人保留所有决策权。下文会展示几个"碰撞拉扯"的具体例子,看完你就知道为什么 AI 的第一稿不能直接用。

二、技术选型:为什么换掉了 VS2022 + gtest?

PDF 原方案是 VS2022 + 自带 gtest,我用的是 w64devkit + GCC 16 + GNU Make,理由如下:

维度 PDF 原方案 本项目方案 理由
工具链 VS2022 + .sln + MSBuild w64devkit + GCC 16 + Make 体积小、跨命令行、不需要 IDE
gtest 来源 VS 内置适配器 从 googletest v1.14.0 源码用 cmake 编出 libgtest.a 不绑 VS,克隆即可用
测试入口 VS Test Explorer make test / make gtest 命令行 CI 友好
日志化 make log + PowerShell 脚本时间戳化 见第五节

关键决策:gtest 静态库 libgtest.a / libgtest_main.a 入库提交 ,这意味着任何人 git clone 后无需联网、无需 VS、无需 cmake,直接 make gtest 就能跑。这正是教学项目该有的样子。

三、项目目录与架构总览

3.1 目录结构

text 复制代码
Tetris/
├── include/        # 9 个 .h(逻辑层接口)
├── src/            # 10 个 .c(含 main.c)
├── tests/          # 5 套 .c + 5 套 .cpp + 自制 test_framework.h
├── lib/
│   ├── raylib.h / libraylib.a
│   └── gtest/{include, lib}
├── resources/sounds/   # 7 个 8-bit 音效 wav(脚本生成)
├── docs/           # architecture / phases / testing
├── build/logs/     # 构建日志(make log 自动写入)
├── scripts/
│   ├── build_log.ps1   # 统一构建+日志封装
│   └── gen_sounds.py   # 8-bit 音效合成脚本
├── Makefile
├── README.md / CHANGELOG.md / AGENTS.md
└── .gitignore

3.2 架构图(Mermaid)

#mermaid-svg-2eRZP8eKLGDumHq0{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-2eRZP8eKLGDumHq0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2eRZP8eKLGDumHq0 .error-icon{fill:#552222;}#mermaid-svg-2eRZP8eKLGDumHq0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2eRZP8eKLGDumHq0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2eRZP8eKLGDumHq0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2eRZP8eKLGDumHq0 .marker.cross{stroke:#333333;}#mermaid-svg-2eRZP8eKLGDumHq0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2eRZP8eKLGDumHq0 p{margin:0;}#mermaid-svg-2eRZP8eKLGDumHq0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 .cluster-label text{fill:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 .cluster-label span{color:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 .cluster-label span p{background-color:transparent;}#mermaid-svg-2eRZP8eKLGDumHq0 .label text,#mermaid-svg-2eRZP8eKLGDumHq0 span{fill:#333;color:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 .node rect,#mermaid-svg-2eRZP8eKLGDumHq0 .node circle,#mermaid-svg-2eRZP8eKLGDumHq0 .node ellipse,#mermaid-svg-2eRZP8eKLGDumHq0 .node polygon,#mermaid-svg-2eRZP8eKLGDumHq0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2eRZP8eKLGDumHq0 .rough-node .label text,#mermaid-svg-2eRZP8eKLGDumHq0 .node .label text,#mermaid-svg-2eRZP8eKLGDumHq0 .image-shape .label,#mermaid-svg-2eRZP8eKLGDumHq0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-2eRZP8eKLGDumHq0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2eRZP8eKLGDumHq0 .rough-node .label,#mermaid-svg-2eRZP8eKLGDumHq0 .node .label,#mermaid-svg-2eRZP8eKLGDumHq0 .image-shape .label,#mermaid-svg-2eRZP8eKLGDumHq0 .icon-shape .label{text-align:center;}#mermaid-svg-2eRZP8eKLGDumHq0 .node.clickable{cursor:pointer;}#mermaid-svg-2eRZP8eKLGDumHq0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2eRZP8eKLGDumHq0 .arrowheadPath{fill:#333333;}#mermaid-svg-2eRZP8eKLGDumHq0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2eRZP8eKLGDumHq0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2eRZP8eKLGDumHq0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2eRZP8eKLGDumHq0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2eRZP8eKLGDumHq0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2eRZP8eKLGDumHq0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2eRZP8eKLGDumHq0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2eRZP8eKLGDumHq0 .cluster text{fill:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 .cluster span{color:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2eRZP8eKLGDumHq0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2eRZP8eKLGDumHq0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-2eRZP8eKLGDumHq0 .icon-shape,#mermaid-svg-2eRZP8eKLGDumHq0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2eRZP8eKLGDumHq0 .icon-shape p,#mermaid-svg-2eRZP8eKLGDumHq0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2eRZP8eKLGDumHq0 .icon-shape .label rect,#mermaid-svg-2eRZP8eKLGDumHq0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2eRZP8eKLGDumHq0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2eRZP8eKLGDumHq0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2eRZP8eKLGDumHq0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-2eRZP8eKLGDumHq0 .ui>*{fill:#e3f2fd!important;stroke:#1976d2!important;}#mermaid-svg-2eRZP8eKLGDumHq0 .ui span{fill:#e3f2fd!important;stroke:#1976d2!important;}#mermaid-svg-2eRZP8eKLGDumHq0 .logic>*{fill:#fff3e0!important;stroke:#f57c00!important;}#mermaid-svg-2eRZP8eKLGDumHq0 .logic span{fill:#fff3e0!important;stroke:#f57c00!important;} main.c 主循环
Input
Renderer
Audio
Game Controller 状态机
Board 10×20
Bag 7-bag
Scoring 计分/等级
MyCircularQueue
Tetromino 7 形状

蓝色 是 UI 层(依赖 raylib,不做单元测试),橙色 是逻辑层(纯 C,不依赖 raylib,接受双测试框架覆盖)。这条边界从第一天就划清楚,后面所有 review 都围绕它做。

3.3 模块职责

模块 文件 职责
MyCircularQueue include/MyCircularQueue.h + src/MyCircularQueue.c 通用循环队列(Bag 的底层依赖)
tetromino include/tetromino.h + src/tetromino.c 7 种方块 4×4 矩阵 + 4 旋转态 + NES 颜色
board include/board.h + src/board.c 10×20 网格 / 碰撞 / 消行压缩算法
bag include/bag.h + src/bag.c 7-bag randomizer
scoring include/scoring.h + src/scoring.c NES 计分 / 等级 / Combo / 速度
game include/game.h + src/game.c 状态机 + 操作入口
renderer / input / audio src/*.c raylib UI 层
main src/main.c 主循环装配

四、11 个 Phase 的实施与"碰撞拉扯"

PDF 把 TODO 分成 11 个 Phase,UI 与逻辑交替。我只挑 真正有故事 的几个讲,完整进度见仓库 docs/phases.md

4.1 Phase 1:脚手架 + 循环队列

PDF 第 5 节直接给了 MyCircularQueue 的完整源码,这是项目唯一"原样照搬"的模块。

c 复制代码
// MyCircularQueue.h(节选)
typedef struct {
    int* a;     // 底层数组(容量为 k+1,多用一格解决假溢出)
    int head;   // 指向头
    int tail;   // 指向尾下一个
    int k;      // 队列容量
} MyCircularQueue;

关键技巧:obj->a = malloc(sizeof(int) * (k + 1)),多开一格tail+1 % capacity == head 判满,完美避开"假溢出"陷阱。后面 7-bag 就站在这个队列之上。

Makefile 接入 raylib 也在这步完成:

makefile 复制代码
CC      = gcc
CFLAGS  = -std=c11 -Wall -Wextra -O2 -Iinclude -Ilib -Isrc
LDFLAGS = -Llib -lraylib -lopengl32 -lgdi32 -lwinmm

Windows 下链 raylib 静态库必须补 lopengl32 lgdi32 lwinmm 三个系统库,少了任一个链接报错。这是 GCC + raylib 在 Windows 的"标配"。

4.2 Phase 2:消行算法的"碰撞拉扯" ⭐

这是 PDF 第 5.1 节的核心案例。AI 第一稿给的消行是教科书式的"满行→上方整体下移":

c 复制代码
if (fullLine) {
    for (int r = row; r > 0; r--) {
        for (int col = 0; col < BOARD_WIDTH; col++) {
            board->grid[r][col] = board->grid[r - 1][col];  // 逐行下移
        }
    }
    // ...
}

review 后我提了两个问题:

  1. 方块都是 重力堆叠 出来的,遇到空行,上面就不可能再有方块了吧?
  2. boardClearLines 整体逻辑是不是也可以:当前行全空,就跳出循环?

AI 同意了,改成 压缩算法,writeRow / readRow 双指针:

c 复制代码
// 消行:压缩算法
int boardClearLines(Board* board) {
    int cleared = 0;
    int writeRow = BOARD_HEIGHT - 1;

    for (int readRow = BOARD_HEIGHT - 1; readRow >= 0; readRow--) {
        // 遇到空行:上方不可能再有方块,提前结束
        if (boardIsRowEmpty(board, readRow)) {
            break;
        }
        if (boardIsRowFull(board, readRow)) {
            cleared++;              // 满行跳过,等效删除
        } else {
            if (writeRow != readRow) {   // 保留行复制到 writeRow
                for (int col = 0; col < BOARD_WIDTH; col++) {
                    board->grid[writeRow][col] = board->grid[readRow][col];
                }
            }
            writeRow--;
        }
    }
    // writeRow 之上的所有行全部清零
    for (int row = 0; row <= writeRow; row++) {
        for (int col = 0; col < BOARD_WIDTH; col++) {
            board->grid[row][col] = 0;
        }
    }
    return cleared;
}

复杂度对比:原版最坏 O ( N 2 ) O(N^2) O(N2)(每次满行都整体下移),压缩版 严格 O ( N ) O(N) O(N),因为每行最多被读一次、写一次。

⚠ 这个优化的前提是 重力场景:棋盘上方不会"浮空方块"。所以单元测试不能构造"row 3 有方块、row 4~19 全空"的非法场景来验证它,否则算法会按"浮空被压缩"语义处理。我第一版测试就这么写,失败了一例,改成纯重力场景后通过。这就是 review 测试用例本身的价值。

4.3 Phase 4:7-bag 与"1000 轮验证"

7-bag 规则:任意连续 7 个方块,7 种形状各出现一次,顺序随机。实现非常薄:

c 复制代码
// Fisher-Yates 洗牌
static void shuffle(int* arr, int n) {
    for (int i = n - 1; i > 0; i--) {
        int j = rand() % (i + 1);
        int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
    }
}

static void refillBag(Bag* bag) {
    int arr[PIECE_COUNT];
    for (int i = 0; i < PIECE_COUNT; i++) arr[i] = i;
    shuffle(arr, PIECE_COUNT);
    for (int i = 0; i < PIECE_COUNT; i++) {
        myCircularQueueEnQueue(bag->queue, arr[i]);
    }
}

真正难的是测试。PDF review 后补了一条:bag.Reset_StillFollowsBagRule 原版没验证 reset 后的预览,我补上了:

cpp 复制代码
TEST(BagTest, BagRuleEverySeven_HoldsFor1000Rounds) {
    Bag b; bagInit(&b);
    for (int round = 0; round < 1000; round++) {
        int out[PIECE_COUNT];
        for (int i = 0; i < PIECE_COUNT; i++) {
            out[i] = (int)bagNext(&b);
        }
        ASSERT_TRUE(permutationOfAll(out)) << "Round " << round << " violated";
    }
    bagFree(&b);
}

连续 1000 轮(7000 次 bagNext)每 7 个都是 [0..6] 的全排列,这个用例一旦失败会打印是第几轮违规,直接定位问题。

4.4 Phase 5:Ghost 与 Next 预览的视觉调整

AI 第一版的 Ghost Piece 用半透明方块,review 后发现 区分度不够。改成白色虚线方框 + 4 段小白线模拟虚线:

c 复制代码
for (int k = 4; k < CELL_SIZE - 4; k += 8) {
    DrawLine(x + 2, y + k, x + 7, y + k, white);          // 左边
    DrawLine(x + CELL_SIZE - 8, y + k, x + CELL_SIZE - 3, y + k, white); // 右边
    DrawLine(x + k, y + 2, x + k, y + 7, white);          // 上边
    DrawLine(x + k, y + CELL_SIZE - 8, x + k, y + CELL_SIZE - 3, white); // 下边
}

Next 预览同理:第一版每个方块单独背景色 → 太花,改成一个 大外框内竖排 3 个 方块,15 px 小格,无背景色。视觉立刻干净了。

这是 Vibe Coding 的典型场景:AI 能给你 80 分的方案,剩下 20 分的"美感/手感"得人眼来调。

4.5 Phase 7:DAS(Delayed Auto Shift)输入机制

DAS 是俄罗斯方块的 经典输入手感:按住方向键,首次立即触发一格,然后延迟一段时间,之后以更高频率自动重复。

PDF 给的代码逻辑很清晰,我直接照搬到 input.c:

c 复制代码
int inputUpdateDAS(InputState* state) {
    bool leftDown = IsKeyDown(KEY_LEFT);
    bool rightDown = IsKeyDown(KEY_RIGHT);

    if (leftDown) {
        if (!state->moveLeftHeld) {                  // 首次按下
            state->moveLeftHeld = true;
            state->moveLeftTimer = DAS_INITIAL_DELAY; // 15 帧 ≈ 250ms
            if (!rightDown) return -1;
        } else {                                     // 持续按住
            state->moveLeftTimer--;
            if (state->moveLeftTimer <= 0) {
                state->moveLeftTimer = DAS_REPEAT_RATE; // 4 帧 ≈ 67ms
                if (!rightDown) return -1;
            }
        }
    } else {
        state->moveLeftHeld = false;
        state->moveLeftTimer = 0;
    }
    // 右键 DAS 对称,略...
}

两个手感参数:DAS_INITIAL_DELAY = 15 帧、DAS_REPEAT_RATE = 4 帧。这是经典手感,改成 10/2 会过灵敏,改成 30/8 会过迟钝。

还有一个细节:左右键 同时按住 时,右键优先返回 1,左键"先检测但不返回"。避免左右同按时方块左右抖动。

4.6 Phase 8:NES 计分与速度曲线

NES 经典计分公式 + Combo 递增,一行代码也不多:

c 复制代码
int scoringOnClearLines(Scoring* sc, int cleared) {
    if (cleared <= 0) { sc->combo = 0; return 0; }   // 未消行重置 combo
    if (cleared > 4) cleared = 4;
    sc->combo++;
    int base  = scoringBaseScore(cleared);            // 100/300/500/800
    int bonus = 50 * sc->combo * sc->level;           // Combo 递增
    int gain  = base * sc->level + bonus;
    sc->score += gain;
    sc->lines += cleared;
    int newLevel = 1 + sc->lines / LINES_PER_LEVEL;   // 每 10 行升 1 级
    if (newLevel > MAX_LEVEL) newLevel = MAX_LEVEL;   // 20 级封顶
    sc->level = newLevel;
    return gain;
}

速度曲线用 max(800 - (level-1) × 40, 80) ms:

Level 下落间隔
1 800 ms
2 760 ms
10 440 ms
19 80 ms
20 80 ms(封顶)

测试覆盖到 level 20 用 level 20 计分、越界封顶、Combo 累加 / 重置等 14 个用例,确保改 scoring 模块不会偷偷破坏等级曲线。

五、构建系统:Makefile + 一键 CI + 时间戳日志

5.1 Makefile 目标全景

makefile 复制代码
all: build
build: $(BIN)
run: build                # 编译并运行
test: tests/run_tests.exe      # assert 版
gtest: tests/run_gtest.exe     # gtest 版
test-all: test gtest
ci: clean build test gtest     # 一键 CI
log:                            # CI + 时间戳化日志
	powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build_log.ps1 -Targets ci
clean:
	rm -f $(OBJ) $(LIB_OBJ) $(BIN) tests/run_tests.exe tests/run_gtest.exe
.PHONY: all build run test gtest test-all ci log clean

make ci 是开发主命令 --- 干净构建 + 两套测试,任一失败立即非 0 退出。

5.2 时间戳化日志(scripts/build_log.ps1)

这是个对标 CI 系统的小封装,把 make ci 的所有 stdout/stderr 写入 build/logs/build_<时间戳>.log,默认保留最近 30 份:

powershell 复制代码
powershell -File scripts/build_log.ps1 -Targets ci         # 等价 make log
powershell -File scripts/build_log.ps1 -Targets test,gtest -Keep 10

每份日志尾部自动生成 Build Summary,可以直接 grep 看哪一项目标失败:

text 复制代码
==================== Build Summary ====================
  [ci] exit=0 warnings=0 errors=0
  Elapsed: 00:00:13.7828996
  Overall exit code: 0
  Log: build\logs\build_20260729_184637.log
=======================================================
Kept 3 log(s) (limit 30).

踩坑记录:PowerShell 5.1 下 Get-ChildItem 单文件时返回 scalar 而非数组,.Count 会失效,导致"保留最近 N 份"的逻辑不生效。修复办法是外层套 @() 强制为数组:

powershell 复制代码
$allLogs = @(Get-ChildItem -LiteralPath $LogRoot -Filter "build_*.log" |
    Sort-Object LastWriteTime -Descending)

这种小坑在 Windows 命令行生态里很常见,日志系统必须自己扛过去。

六、双测试框架的工程价值

项目同时维护两套测试:

框架 文件 入口 用例数
assert tests/test_*.c + test_main.c 自制聚合入口 52
gtest tests/test_*.cpp + libgtest_main libgtest 自带 main 52

合计 104 用例,分布在 5 个模块:Tetromino 6 / Board 14 / Bag 6 / Game 12 / Scoring 14

6.1 为什么要两套?

  • PDF 原方案是 gtest,保留它跟标准行为对齐
  • assert 版零依赖 :即使把 lib/gtest/ 整个目录删掉,测试依然可跑。教学场景、CI 沙箱、最小化环境都能用
  • 同份逻辑层被两套分别链接:能即时发现编译器差别或 ABI 引发的潜在问题

6.2 gtest 版的写法

cpp 复制代码
#include <gtest/gtest.h>
extern "C" {                // ← 必须 extern "C",否则 C++ 会 name-mangle
#include "bag.h"
}

TEST(BagTest, BagRuleEverySeven_HoldsFor1000Rounds) {
    Bag b; bagInit(&b);
    for (int round = 0; round < 1000; round++) {
        int out[PIECE_COUNT];
        for (int i = 0; i < PIECE_COUNT; i++) out[i] = (int)bagNext(&b);
        ASSERT_TRUE(permutationOfAll(out)) << "Round " << round << " violated";
    }
    bagFree(&b);
}

运行结果:[==========] 52 tests from 5 test suites ran. (0 ms total) [ PASSED ] 52 tests.

七、C/C++ 混编链接陷阱 ⭐

这是整个项目最容易踩的坑,单独拎出来讲。

问题 :gtest 是 .cpp,被测代码是 .c。如果你让 g++ 直接编译 .c:

bash 复制代码
g++ ... test_tetromino.cpp src/tetromino.c ...    # ❌ 错误做法

g++ 会把 .c 当 C++ 处理,函数名被 name-mangling(tetrominoGetShape_Z19tetrominoGetShapeii)。

.cpp 里的 extern "C" 期望的是原始 C 符号 → 链接报几十个 undefined reference

正确做法 :用 gcc 预编译 .c 为 .o,g++ 只编译 .cpp 并链接 .o:

makefile 复制代码
# 通用 .c -> .o 规则(用 gcc,保持 C ABI)
%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<

# gtest 链接:先依赖 .o,再用 g++ 编译 .cpp 并链接
tests/run_gtest.exe: $(GTEST_SRC) $(LIB_OBJ)
	$(CXX) $(CXXFLAGS) -o $@ $(GTEST_SRC) $(LIB_OBJ) $(GTEST_LDFLAGS)

这样 .o 里是原始 C 符号,.cpp 里的 extern "C" 能正确解析,链接成功。

经验法则:永远不要让 g++ 编译 .c 。在 MinGW + GoogleTest 的组合下,这是必须记住的一条铁律。我把它写进了 AGENTS.md 给后续的 AI 代理看。

八、音效自合成:纯 Python 标准库生成 8-bit wav

PDF 要求 7 个 8-bit 风格音效,但没说怎么弄到。我让AI写了个 纯标准库 脚本 scripts/gen_sounds.py(wave + math + random),不依赖 numpy/scipy:

8.1 合成原语

python 复制代码
def square(freq, n, sr=SAMPLE_RATE, duty=0.5):
    """方波:占空比 duty(0~1),返回 ±1 序列。"""
    out = [0.0] * n
    period = sr / freq
    for i in range(n):
        phase = (i % period) / period
        out[i] = 1.0 if phase < duty else -1.0
    return out

def env_exp_decay(n, decay_ms, sr=SAMPLE_RATE):
    """指数衰减包络(用于撞击/爆炸)。"""
    tau = decay_ms / 1000.0
    return [math.exp(-i / sr / tau) for i in range(n)]

8.2 每个事件的"音色配方"

文件 时长 合成方案
move.wav 40 ms 660 Hz 方波 + ADSR,音量低
rotate.wav 80 ms 扫频 500→1000 Hz 方波
lock.wav 120 ms 150 Hz 方波 + 白噪声,指数衰减(撞击感)
clear.wav 150 ms 三音上升 C5-E5-G5 方波琶音
tetris.wav 300 ms 四音上升 C5-E5-G5-C6,叠加八度让声音更亮
levelup.wav 200 ms 双音上升 G5→C6
gameover.wav 450 ms 三音下降 G4-E4-C4,指数衰减(哀悼感)

8.3 撞击声示例

python 复制代码
def gen_lock():
    # 撞击声:低频方波 150Hz + 衰减白噪声, ~120ms
    n = int(SAMPLE_RATE * 0.12)
    low = square(150, n, duty=0.5)
    nz  = noise(n, seed=42)
    body = mix(low, [v * 0.6 for v in nz])
    env  = env_exp_decay(n, decay_ms=80)
    return [v * 0.6 for v in apply_env(body, env)]

运行:

bash 复制代码
python scripts/gen_sounds.py
# Generating 8-bit sound effects into: resources/sounds
#   move.wav         40.0 ms    1808 bytes
#   rotate.wav       80.0 ms    3572 bytes
#   lock.wav        120.0 ms    5336 bytes
#   clear.wav       149.9 ms    6656 bytes
#   tetris.wav      299.9 ms   13268 bytes
#   levelup.wav     200.0 ms    8864 bytes
#   gameover.wav    449.9 ms   19886 bytes
# Done. 7 files generated.

7 个文件 共 ~70 KB,16-bit PCM mono 22050 Hz,raylib LoadSound 直接吃。游戏代码侧对缺失文件 自动静默降级(audio.c:38 fileExists 检测),所以即便不跑这个脚本,游戏也能正常跑,只是没声音。

想换音色就改 gen_sounds.py 里的频率/包络/波形,再跑一次,无需重编译 C 代码。

九、验收与最终结果

9.1 PDF 15 条验收标准

# 验收标准 测试方式
1 窗口 + 主游戏区 10×20 + 格子 30px 手动确认
2 7 种方块 NES 颜色 + 内凹 3D 手动确认
3 Ghost piece 正确显示落点 手动确认
4 Next 预览显示下一个 手动确认
5 方向键 + 空格 + P 操作正确 手动确认
6 P 键暂停,画面冻结 手动确认
7 硬降到底,软降加速 手动确认
8 消行规则:满行消除 + 上方下落 + 多行同消 gtest + 手动
9 7-bag 每 7 个含 7 形状各一次,1000 轮不违反 gtest 确认
10 NES 计分 + Combo 递增 gtest 确认
11 等级上升加速,20 级封顶 gtest + 手动
12 Game Over 触顶判定 + 任意键重玩 手动确认
13 7 种音效对应事件播放 手动确认
14 界面布局完整:主区 + Next + HUD + 操作提示 + 标题 手动确认
15 HUD 实时更新分数/等级/消行 手动确认

9.2 一键 CI 输出

text 复制代码
$ make log
==================== [ make ci ] ====================
gcc -std=c11 -Wall -Wextra -O2 ... -o tetris.exe ...    # build OK
gcc ... -o tests/run_tests.exe ...                       # assert OK
./tests/run_tests.exe
========================
PASSED: 52
FAILED: 0
========================
g++ -std=c++17 ... -o tests/run_gtest.exe ...           # gtest OK
./tests/run_gtest.exe
[==========] 52 tests from 5 test suites ran. (0 ms total)
[  PASSED  ] 52 tests.

==================== Build Summary ====================
  [ci] exit=0 warnings=0 errors=0
  Elapsed: 00:00:13.7828996
  Overall exit code: 0
  Log: build\logs\build_20260729_184637.log
=======================================================

104 用例全 PASS,编译零警告(-Wall -Wextra),tetris.exe 1.8 MB。

十、Vibe Coding 的三条经验

走完 11 个 Phase,我对 Vibe Coding 的体会浓缩成三条:

  1. AI 第一稿永远是 80 分 。消行算法、Ghost 区分度、Next 预览样式、窗口尺寸... 全是 review 后才调到 90 分。人必须保持决策权。
  2. 测试用例本身也要 review 。AI 写的 bag.Reset 测试漏了预览验证,我补上才完备;AI 写的"浮空方块"测试场景违反重力前提,改成纯重力场景才对。测试代码是第一公民。
  3. 工程系统比单点功能更重要Makefile 的 8 个目标、build_log.ps1 的时间戳日志、AGENTS.md 的代理约定、gen_sounds.py 的可重生音效 --- 这些"周边"决定了项目能不能 接力 下去。

最后,所有产物都开源在仓库里:Makefile / scripts/ / docs/ / tests/ / resources/sounds/,克隆即用,不依赖任何 IDE。


参考资料

如果觉得这套"Vibe Coding + 双测试 + 构建日志 + 自合成音效"的工程化做法有启发,

欢迎点赞收藏。后续会继续写 C 语言数据结构项目 系列文章。


  1. PDF 原文提示词见仓库根目录 4.Vibe Coding实现俄罗斯方块.pdf 第 3.1 节。 ↩︎
相关推荐
浩浩kids14 小时前
C•exercises
c语言
春生野草15 小时前
个人笔记——C语言字符串、树
c语言·开发语言·笔记
冻柠檬飞冰走茶20 小时前
PTA基础编程题目集 7-24 约分最简分式(C语言实现)
c语言·开发语言·数据结构·算法
weixin_5859511721 小时前
CH592F 2.4G 低功耗
c语言·开发语言
盒子里的加菲猫21 小时前
什么?不会C语言也可以搭建窗口级应用程序?(Electron)
c语言·开发语言·electron
caishenzhibiao1 天前
无极多空共振 同花顺期货通指标
java·c语言·c#
modelmd1 天前
C 语言基础数据类型详解:大小与内存存储
c语言
hjlgs1 天前
七. C语言形参-实参
c语言·开发语言
ziguo11221 天前
C/C++ 中的 struct:从 C 到 C++ 的进化
c语言·c++