Wireworld Simulator Using the Raylib: Part2¶
继续上一部分。
这部分主要实现 无限画布 的功能,包含视角移动、视图缩放、网格自动拓展。
项目地址:github.com/13m0n4de/wireworld
修复网格外绘制造成的问题¶
某些情况下,窗口大小会大于网格大小,鼠标在网格外点击会导致 grid 下标溢出。
限制范围:
diff --git a/main.c b/main.c
index 828e1c8..f86f289 100644
--- a/main.c
+++ b/main.c
@@ -252,8 +252,12 @@ void HandleUserInput(void) {
         !CheckCollisionPointRec(mousePosition, playButtonRect) &&
         !CheckCollisionPointRec(mousePosition, nextButtonRect) &&
         !CheckCollisionPointRec(mousePosition, indicatorRect)) {
-        grid[mouseYGridPos][mouseXGridPos] = selectCellType;
-        DrawCell(mouseXGridPos, mouseYGridPos, GetCellColor(selectCellType));
+        if ((mouseXGridPos >= 0 && mouseXGridPos < cols) &&
+            (mouseYGridPos >= 0 && mouseYGridPos < rows)) {
+            grid[mouseYGridPos][mouseXGridPos] = selectCellType;
+            DrawCell(mouseXGridPos, mouseYGridPos,
+                     GetCellColor(selectCellType));
+        }
     }
     DrawCellLines(mouseXGridPos, mouseYGridPos, WHITE);
使用 calloc 初始化网格¶
使用 calloc 可以在分配内存的同时将内存初始化为 0,也正好就是 EMPTY,省去额外遍历初始化的过程。
void InitGrid(void) {
    grid.cells = RL_CALLOC(grid.rows, sizeof(Cell*));
    for (int y = 0; y < grid.rows; y++) {
        grid.cells[y] = RL_CALLOC(grid.cols, sizeof(Cell));
    }
}
视角移动¶
无限画布的前置功能,按住鼠标右键拖动画布。
试了几个方案,最后还是用了 2D 摄像头,通过移动摄像头位置模拟画布拖动,这种方法不会太过复杂。
Camera2D camera = {0};
camera.zoom = 1.0f;
if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) {
    Vector2 delta = GetMouseDelta();
    delta = Vector2Scale(delta, -1.0f / camera.zoom);
    camera.target = Vector2Add(camera.target, delta);
}
这时网格的绘制逻辑需要放在 2D 模式中(BeginMode2D 和 EndMode2D 之间)。
由于 HandleUserInput 函数需要在鼠标左击时绘制「细胞」以及在鼠标悬停时绘制「预览边框」,所以也需要放在 2D 模式中:
BeginMode2D(camera);
float frameTime = GetFrameTime();
elapsedTime += frameTime;
if (isPlaying && elapsedTime >= refreshInterval) {
    UpdateGrid();
    elapsedTime = 0.0f;
}
for (int y = 0; y < rows; y++) {
    for (int x = 0; x < cols; x++) {
        Color cellColor = GetCellColor(grid[y][x]);
        DrawCell(x, y, cellColor);
    }
}
HandleUserInput();
EndMode2D();
按钮我希望保持屏幕固定位置,不会被脱拽移动,它们在结束 2D 模式后绘制:
EndMode2D();
DrawIndicators();
DrawPlayButton();
DrawNextButton();
顺带把 HandleUserInput 重构了,拆成几个函数:
void HandleUserInput(void) {
    HandleCellPlacements();
    HandleButtonClicks();
    HandleShortcuts();
    HandleCameraMovement();
}
网格拓展¶
策略¶
策略很简单,下面几张图展示了一个基本案例。
初始时相机和网格坐标一致,同为 (0, 0)。网格大小 20 x 12,其中有一细胞位于 [9, 5] 处(第 6 行,第 10 列)。
当按住鼠标右键向右下角滑动时,相机向左上角移动到 (-8, -4),越过网格的横纵边界。
此时网格需向 X 和 Y 方向拓展一个固定的单位长度,这里设置为 8。于是网格坐标变成了 (-8, -8),大小变成了 28 x 20。
细胞变成 [17, 13],在网格中的位置变化了,但相对相机的位置没变,屏幕上不会有感知。
只在越过边界时拓展网格,同时选择一个合适的值作为每次拓展的单位长度,可以避免频繁地内存分配。
当相机越过右或下边界时,网格坐标不需要更改,只需要拓展大小。
代码¶
因为需要同时管理网格的坐标、长宽和细胞,所以创建 Grid 结构体用来表示网格信息:
typedef struct {
    Vector2 position;
    int rows;
    int cols;
    Cell** cells;
} Grid;
一些代码要进行相应更改,比如:
for (int y = 0; y < grid.rows; y++) {
    for (int x = 0; x < grid.cols; x++) {
        Color cellColor = GetCellColor(grid.cells[y][x]);
        DrawCell(x, y, cellColor);
    }
}
移动相机时,检查是否越过网格边界,并针对特定方向拓展网格。
void HandleCameraMovement(void) {
    if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) {
        Vector2 delta = GetMouseDelta();
        delta = Vector2Scale(delta, -1.0f / camera.zoom);
        camera.target = Vector2Add(camera.target, delta);
        Vector2 cameraOffset = Vector2Subtract(camera.target, grid.position);
        bool isOutsideX = cameraOffset.x + screenWidth >
                          grid.position.x + grid.cols * cellSize;
        bool isOutsideY = cameraOffset.y + screenHeight >
                          grid.position.y + grid.rows * cellSize;
        if (cameraOffset.x < 0)
            ExpandGrid(LEFT);
        if (cameraOffset.y < 0)
            ExpandGrid(UP);
        if (isOutsideX)
            ExpandGrid(RIGHT);
        if (isOutsideY)
            ExpandGrid(DOWN);
    }
}
根据拓展方向计算新的行列数,创建新的 cells。计算偏移,将之前的细胞按照偏移放置在新 cells 中,以确保它们在屏幕上的位置不变。最后使用新的数据更新 grid 完成拓展:
const int gridIncrement = 10;
void ExpandGrid(Direction direction) {
    int newRows =
        grid.rows + (direction == UP || direction == DOWN ? gridIncrement : 0);
    int newCols = grid.cols +
                  (direction == LEFT || direction == RIGHT ? gridIncrement : 0);
    int xOffset = (direction == LEFT ? gridIncrement : 0);
    int yOffset = (direction == UP ? gridIncrement : 0);
    Cell** newCells = RL_CALLOC(newRows, sizeof(Cell*));
    for (int y = 0; y < newRows; y++) {
        newCells[y] = RL_CALLOC(newCols, sizeof(Cell));
    }
    for (int y = 0; y < grid.rows; y++) {
        for (int x = 0; x < grid.cols; x++) {
            newCells[y + yOffset][x + xOffset] = grid.cells[y][x];
        }
    }
    FreeGrid();
    grid.cells = newCells;
    grid.rows = newRows;
    grid.cols = newCols;
    grid.position.x -= cellSize * xOffset;
    grid.position.y -= cellSize * yOffset;
}
算得上是无限画布了。
仅绘制可见部分¶
网格过大时会出现明显的卡顿,因为视图外的细胞也照常绘制了。
可以通过计算当前相机视野内的网格单元格范围,只绘制这些单元格:
void DrawVisibleCells(void) {
    Vector2 topLeft = GetScreenToWorld2D(Vector2Zero(), camera);
    Vector2 bottomRight =
        GetScreenToWorld2D((Vector2){screenWidth, screenHeight}, camera);
    int startX = (int)floor((topLeft.x - grid.position.x) / cellSize);
    int startY = (int)floor((topLeft.y - grid.position.y) / cellSize);
    int endX = (int)ceil((bottomRight.x - grid.position.x) / cellSize);
    int endY = (int)ceil((bottomRight.y - grid.position.y) / cellSize);
    startX = Clamp(startX, 0, grid.cols);
    startY = Clamp(startY, 0, grid.rows);
    endX = Clamp(endX, 0, grid.cols);
    endY = Clamp(endY, 0, grid.rows);
    for (int y = startY; y < endY; y++) {
        for (int x = startX; x < endX; x++) {
            Color cellColor = GetCellColor(grid.cells[y][x]);
            DrawCell(x, y, cellColor);
        }
    }
}
视图缩放¶
最初实验了使用相机的 zoom 字段完成缩放,但发现效果不怎么好,缩得太小时网格线会变得不清晰甚至消失,并且给许多计算引入了新变量。
所以,最终还是使用了调整细胞大小 cellSize 的方案,以此模拟“视图的缩放”。
滚动鼠标滚轮时,调整细胞大小和网格位置,确保网格会以鼠标指针所在的位置为中心进行缩放,使得鼠标指针所指的网格单元在缩放前后保持不变:
void HandleZoom(void) {
    float wheel = GetMouseWheelMove();
    if (wheel != 0) {
        int newCellSize = cellSize + wheel * zoomSpeed;
        if (newCellSize >= minCellSize && newCellSize <= maxCellSize) {
            Vector2 mousePos = GetScreenToWorld2D(GetMousePosition(), camera);
            Vector2 gridPos =
                Vector2Divide(Vector2Subtract(mousePos, grid.position),
                              (Vector2){cellSize, cellSize});
            cellSize = newCellSize;
            grid.position = Vector2Subtract(
                mousePos,
                Vector2Multiply(gridPos, (Vector2){cellSize, cellSize}));
        }
    }
}
拓展网格在视图的移动和缩放结束后进行:
void HandleCameraMovement(void) {
    if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) {
        Vector2 delta = GetMouseDelta();
        delta = Vector2Scale(delta, -1.0f / camera.zoom);
        camera.target = Vector2Add(camera.target, delta);
    }
}
void HandleUserInput(void) {
    HandleCellPlacements();
    HandleButtonClicks();
    HandleShortcuts();
    HandleCameraMovement();
    HandleZoom();
    Vector2 cameraOffset = Vector2Subtract(camera.target, grid.position);
    if (cameraOffset.x < 0) {
        ExpandGrid(LEFT);
    }
    if (cameraOffset.y < 0) {
        ExpandGrid(UP);
    }
    if (cameraOffset.x + screenWidth > grid.position.x + grid.cols * cellSize) {
        ExpandGrid(RIGHT);
    }
    if (cameraOffset.y + screenHeight >
        grid.position.y + grid.rows * cellSize) {
        ExpandGrid(DOWN);
    }
}
总结¶
最终代码见 github.com/13m0n4de/wireworld/blob/main/main.c。
下一步计划:
- 导入导出
- 设置页面

