c_tetris

第 4 课:游戏循环 🔄

4.1 本课目标

理解游戏循环的工作原理,学会如何控制游戏节奏和状态更新。


4.2 什么是游戏循环?

游戏循环 = 游戏的心脏

所有游戏(从俄罗斯方块到 3A 大作)都有一个核心循环,不断重复以下流程:

┌─────────────────┐
│   1. 处理输入    │ ← 玩家按键
│   (Input)       │
└────────┬────────┘
         ↓
┌─────────────────┐
│   2. 更新状态    │ ← 方块下落、碰撞检测
│   (Update)      │
└────────┬────────┘
         ↓
┌─────────────────┐
│   3. 渲染画面    │ ← 绘制到屏幕
│   (Render)      │
└────────┬────────┘
         ↓
    继续循环?
    ╱       ╲
   是        否
   ↓         ↓
 继续      退出

4.3 为什么需要游戏循环?

问题:如果没有循环会怎样?

// ❌ 错误做法:只执行一次
input_handle();   // 处理一次输入
game_update();    // 更新一次状态
render();         // 绘制一次画面
// 然后程序就结束了!

结果: 画面静止,无法响应输入,游戏无法进行。

正确做法:无限循环

// ✅ 正确做法:持续循环
while (game->running) {
    input_handle();
    game_update();
    render();
    sleep(50ms);  // 控制速度
}

好处:


4.4 代码实现详解

主循环结构

// main.c
int main(void) {
    // 1. 初始化(只执行一次)
    render_init();
    load_high_scores();
    
    Game* game = game_create();
    game_init(game);
    
    // 2. 游戏主循环(重复执行)
    while (game->running) {
        // 2.1 处理输入
        int key = input_get_key();
        if (key != ERR) {
            input_handle_game(game, key);
        }
        
        // 2.2 更新游戏状态
        game_update(game);
        
        // 2.3 渲染画面
        game_render(game);
        
        // 2.4 控制速度
        napms(game->paused ? 100 : 50);
    }
    
    // 3. 清理(只执行一次)
    game_destroy(game);
    render_cleanup();
    return 0;
}

为什么这样组织?

初始化 → 循环 → 清理 是标准程序结构:

┌─────────────────────────────────────┐
│  初始化                              │
│  - 分配内存                          │
│  - 设置初始状态                      │
│  - 加载资源                          │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│  主循环 (while running)             │
│  - 处理输入                          │
│  - 更新状态                          │
│  - 渲染画面                          │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│  清理                                │
│  - 释放内存                          │
│  - 保存数据                          │
│  - 关闭资源                          │
└─────────────────────────────────────┘

4.5 输入处理

非阻塞输入

// input.c
int input_get_key(void) {
    return getch();  // ncurses 函数
}

关键点:非阻塞

// 在 render_init() 中设置
nodelay(stdscr, TRUE);  // getch() 不等待,立即返回

对比:

模式 行为 适用场景
阻塞 getch() 等待用户按键 菜单、对话
非阻塞 getch() 立即返回 动作游戏、实时响应

为什么俄罗斯方块用非阻塞?

返回值处理

int key = input_get_key();

if (key == ERR) {
    // 没有按键,继续循环
    // ERR 是 ncurses 常量,表示无输入
} else {
    // 有按键,处理
    input_handle_game(game, key);
}

4.6 状态更新

方块下落逻辑

// game.c
void game_update(Game* game) {
    if (!game || game->paused || game->game_over) return;
    
    // 方块下落一格
    game->current.y++;
    
    // 检查碰撞
    if (board_check_collision(&game->board, &game->current)) {
        // 碰撞了,退回一格
        game->current.y--;
        
        // 锁定方块到游戏板
        board_lock_tetromino(&game->board, &game->current);
        
        // 检查并消除行
        int lines = board_clear_lines(&game->board);
        
        // 生成新方块
        game->current = game->next;
        tetromino_init(&game->next, random_tetromino());
        
        // 检查游戏结束
        if (board_check_collision(&game->board, &game->current)) {
            game->game_over = true;
        }
    }
}

为什么先移动再检查碰撞?

步骤分解:

1. 假设方块在 y=5
   ┌────┐
   │ ██ │ y=5
   └────┘

2. 先下落:y++ → y=6
   ┌────┐
   │    │ y=5
   │ ██ │ y=6  ← 可能已经碰撞
   └────┘

3. 检查碰撞
   - 如果碰撞:y-- 退回 y=5,锁定方块
   - 如果没碰撞:保持在 y=6

4. 继续循环...

为什么这样设计?


4.7 渲染画面

渲染顺序很重要

// render.c
void render_game(Game* game) {
    clear();  // 1. 清屏
    
    // 2. 绘制影子(最底层)
    render_ghost(&game->ghost);
    
    // 3. 绘制已固定方块(中间层)
    render_board(&game->board);
    
    // 4. 绘制当前方块(最上层)
    render_tetromino(&game->current);
    
    // 5. 绘制 UI
    render_next_piece(&game->next);
    render_score(game->score, ...);
    
    refresh();  // 6. 刷新屏幕
}

为什么需要这个顺序?

图层叠加原理:

清屏 → 影子 → 固定方块 → 当前方块 → UI
  ↓        ↓         ↓          ↓        ↓
空白    灰色点点   彩色方块    彩色方块  文字
        (底层)    (中层)      (上层)    (最上)

如果顺序错了:
- 先画当前方块,再画影子 → 影子会覆盖当前方块 ❌
- 先画 UI,再画方块 → 方块会覆盖 UI ❌

4.8 控制游戏速度

为什么需要控制速度?

// 如果没有延迟
while (game->running) {
    update();
    render();
    // 没有延迟 → 循环飞快 → 方块瞬间到底!
}

结果: 游戏运行太快,无法玩。

使用 napms 控制速度

napms(game->paused ? 100 : 50);  // 暂停时 100ms,正常 50ms

napms 是什么?

速度计算

假设 napms(50):

1 次循环 = 50ms
1 秒 = 1000ms ÷ 50ms = 20 次循环

但这不是方块下落速度!
方块下落由 game_update() 中的逻辑控制:
- 每次循环 y++(下落一格)
- 但只有碰撞时才真正下落

实际下落速度 = drop_interval(约 3000ms 初始)

4.9 暂停功能实现

暂停状态处理

// 在游戏循环中
if (game->paused) {
    napms(100);  // 暂停时也保持循环,但速度慢
    continue;    // 跳过更新和渲染
}

// 或者在 input_handle_game 中
case 'p':
case 'P':
    game->paused = true;
    break;

暂停时显示提示

// render_game() 中
if (game->paused) {
    attron(A_REVERSE | A_BOLD);
    mvprintw(10, 20, "+-------------+");
    mvprintw(11, 20, "|   PAUSED  |");
    mvprintw(12, 20, "| P-Resume  |");
    mvprintw(13, 20, "+-------------+");
    attroff(A_REVERSE | A_BOLD);
}

4.10 帧率(FPS)概念

什么是 FPS?

FPS = Frames Per Second(每秒帧数)

60 FPS = 每秒渲染 60 次 = 每帧 16.67ms
30 FPS = 每秒渲染 30 次 = 每帧 33.33ms
20 FPS = 每秒渲染 20 次 = 每帧 50ms  ← 我们的游戏

俄罗斯方块需要多少 FPS?

游戏类型 推荐 FPS 原因
俄罗斯方块 20-30 回合制,不需要快速反应
动作游戏 60 需要流畅动画
射击游戏 144+ 竞技,需要极高流畅度

为什么 20 FPS 够用?


4.11 完整游戏循环流程图

┌─────────────────────────────────────────────┐
│              main() 启动                     │
└───────────────────┬─────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│  初始化                                      │
│  - render_init()                            │
│  - load_high_scores()                       │
│  - game_create()                            │
│  - game_init()                              │
└───────────────────┬─────────────────────────┘
                    ↓
         ┌──────────────────────┐
         │  game->running ?     │←────────┐
         └──────────┬───────────┘         │
             Yes    │    No                │
              ↓     │     ↓                │
    ┌───────────────┴─────┐│               │
    │ input_get_key()     ││               │
    │ if (key != ERR)     ││               │
    │   input_handle_game()││              │
    └───────────┬─────────┘│               │
                ↓          │               │
    ┌─────────────────────┐│               │
    │ game_update()       ││               │
    │ - current.y++       ││               │
    │ - check_collision() ││               │
    │ - lock/lines/spawn  ││               │
    └───────────┬─────────┘│               │
                ↓          │               │
    ┌─────────────────────┐│               │
    │ game_render()       ││               │
    │ - clear()           ││               │
    │ - ghost/board/current│              │
    │ - UI/next/score     ││               │
    │ - refresh()         ││               │
    └───────────┬─────────┘│               │
                ↓          │               │
    ┌─────────────────────┐│               │
    │ napms(50)           ││               │
    │ (控制速度)           ││               │
    └───────────┬─────────┘│               │
                │          │               │
                └──────────┴───────────────┘
                           ↓
              ┌────────────────────┐
              │  清理               │
              │  - game_destroy()  │
              │  - render_cleanup()│
              │  - show_high_scores()│
              └────────────────────┘

✅ 本课检查清单


📝 小作业

  1. 修改 napms(50)napms(100),观察游戏有什么变化

  2. game_update() 开头添加调试输出:
    printf("DEBUG: 方块位置 (%d, %d)\n", game->current.x, game->current.y);
    

    运行游戏,观察输出

  3. 尝试在暂停时也调用 game_update(),会发生什么?为什么?

下一课:碰撞检测