前面七章搭出来的世界,交互只有「鼠标左键破坏、右键放置」。这离一个真正好用的编辑器还差得远。这一章用 Qt 的控件定制能力,做一个体素风格的工具箱。

8.1 控件定制 — 体素风格的 UI

Qt 的控件不需要从头画。一套好的 QSS + 少量自绘 = 风格统一、手感对路的编辑器。

8.1.1 全局 QSS — 定色调

/* voxel-editor.qss */
QMainWindow {
    background-color: #1a1a2e;
}

QPushButton {
    background-color: #2d2d44;
    color: #e0e0e0;
    border: 2px solid #4a4a6a;
    border-radius: 0px;          /* 体素风格不要圆角 */
    padding: 6px 16px;
    font-family: "Courier New";  /* 等宽字体和方块主题搭配 */
    font-size: 13px;
    min-height: 28px;
}

QPushButton:hover {
    background-color: #3d3d5c;
    border-color: #7a7aaa;
}

QPushButton:pressed {
    background-color: #1a1a2e;
    border-color: #8a8ac0;
    /* 按下去的效果:凹陷感 */
    padding: 7px 15px 5px 17px;
}

QSlider::groove:horizontal {
    background: #2d2d44;
    height: 6px;
    border: none;
}

QSlider::handle:horizontal {
    background: #7a7aaa;
    width: 12px;
    height: 12px;
    margin: -3px 0;
}

QSlider::handle:horizontal:hover {
    background: #9a9aca;
}

QLabel {
    color: #c0c0cc;
    font-family: "Courier New";
    font-size: 12px;
}

QToolTip {
    background-color: #2d2d44;
    color: #e0e0e0;
    border: 1px solid #4a4a6a;
    padding: 4px;
}

设计原则: 不和操作系统主题混在一起。体素引擎的编辑器应该有自己的视觉身份——暗色底、低饱和度蓝紫色调、方正不圆的边角。这套 QSS 把 QPushButton、QSlider、QLabel 全部统一到一套色板上。

8.1.2 方块选择轮 — 自绘控件

方块选择轮不是一个 QComboBox 能搞定的——它需要显示方块的纹理预览、支持鼠标滚轮翻页、高亮当前选中项。

// BlockSelector.h
#pragma once
#include <QWidget>
#include <QVector>
#include "BlockType.h"

namespace ve {

class BlockSelector : public QWidget {
    Q_OBJECT
public:
    explicit BlockSelector(QWidget *parent = nullptr);

    void setBlocks(const QVector<BlockType> &types);
    BlockType selectedBlock() const { return m_selected; }

signals:
    void blockSelected(BlockType type);

protected:
    void paintEvent(QPaintEvent *event) override;
    void mousePressEvent(QMouseEvent *event) override;
    void wheelEvent(QWheelEvent *event) override;

private:
    QVector<BlockType> m_blocks;
    int m_selectedIndex = 0;
    int m_scrollOffset = 0;
    static constexpr int ITEM_SIZE = 48;
    static constexpr int SPACING = 4;
};

} // namespace ve
// BlockSelector.cpp — 核心自绘逻辑
void BlockSelector::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing, false);  // 体素不要抗锯齿

    int x = SPACING - m_scrollOffset;

    for (int i = 0; i < m_blocks.size(); ++i) {
        // 判断可见区域
        if (x + ITEM_SIZE < 0) { x += ITEM_SIZE + SPACING; continue; }
        if (x > width()) break;

        // 背景
        QRect rect(x, (height() - ITEM_SIZE) / 2, ITEM_SIZE, ITEM_SIZE);
        bool isSelected = (i == m_selectedIndex);

        if (isSelected) {
            // 选中状态:亮色边框 + 放大
            painter.fillRect(rect.adjusted(-2, -2, 2, 2), QColor("#7a7aaa"));
            painter.fillRect(rect, QColor("#3d3d5c"));
        } else {
            painter.fillRect(rect, QColor("#2d2d44"));
        }

        // 绘制边框(体素风格的 2px 边框)
        painter.setPen(QPen(QColor("#4a4a6a"), 2));
        painter.drawRect(rect);

        // 绘制方块纹理预览(简化版:纯色块)
        QColor blockColor = blockTypeToColor(m_blocks[i]);
        painter.fillRect(rect.adjusted(4, 4, -4, -4), blockColor);

        // 方块名称
        if (isSelected) {
            painter.setPen(QColor("#e0e0e0"));
            painter.setFont(QFont("Courier New", 9));
            painter.drawText(rect.adjusted(0, ITEM_SIZE + 4, 0, 16),
                             Qt::AlignHCenter, blockTypeName(m_blocks[i]));
        }

        x += ITEM_SIZE + SPACING;
    }
}

void BlockSelector::mousePressEvent(QMouseEvent *event) {
    int x = SPACING - m_scrollOffset;
    for (int i = 0; i < m_blocks.size(); ++i) {
        QRect rect(x, (height() - ITEM_SIZE) / 2, ITEM_SIZE, ITEM_SIZE);
        if (rect.contains(event->pos())) {
            m_selectedIndex = i;
            emit blockSelected(m_blocks[i]);
            update();
            return;
        }
        x += ITEM_SIZE + SPACING;
    }
}

void BlockSelector::wheelEvent(QWheelEvent *event) {
    m_scrollOffset += event->angleDelta().y() / 2;
    m_scrollOffset = std::max(0, std::min(m_scrollOffset,
        (int)(m_blocks.size() * (ITEM_SIZE + SPACING)) - width() + SPACING));
    update();
}
graph LR
    subgraph WHEEL["方块选择轮"]
        G["草方块<br/>[选中 · 高亮边框]"]
        D["泥土<br/>[普通]"]
        S["石头<br/>[普通]"]
        W["木板<br/>[普通]"]
        GL["玻璃<br/>[普通]"]
    end

    MOUSE["鼠标滚轮 → 左右滚动<br/>鼠标点击 → 选中"]
    SIGNAL["emit blockSelected(type)"]
    WORLD["更新 m_selectedBlockType<br/>下一次右键放置用这个方块"]

    MOUSE --> WHEEL
    WHEEL --> SIGNAL
    SIGNAL --> WORLD

    style G fill:#2ecc71,color:#fff
    style D fill:#e67e22,color:#fff
    style S fill:#7f8c8d,color:#fff

8.1.3 方块色板 — 从类型到颜色

QColor blockTypeToColor(BlockType type) {
    switch (type) {
        case BlockType::Air:    return QColor(0, 0, 0, 0);
        case BlockType::Grass:  return QColor("#5a9e4b");
        case BlockType::Dirt:   return QColor("#8b5e3c");
        case BlockType::Stone:  return QColor("#7f8c8d");
        case BlockType::Wood:   return QColor("#8b6914");
        case BlockType::Leaves: return QColor("#2d5a1e");
        case BlockType::Sand:   return QColor("#e8d5a3");
        case BlockType::Glass:  return QColor(200, 220, 255, 100);
        case BlockType::Water:  return QColor(64, 128, 255, 160);
        default:                return QColor("#ff00ff");  // 品红 = 未定义
    }
}

彩蛋:#ff00ff(品红)作为默认色。这颜色在游戏美术里是「贴图丢失」的标记色——如果一个方块是品红的,你立刻知道 blockTypeToColor 漏了它。


8.2 场景集成 — UI 嵌入 3D 视口

编辑器 UI 不能单独弹一个窗口——它要浮在 3D 视口上面,像游戏里的 HUD。

8.2.1 布局结构

┌──────────────────────────────────────────────┐
│ VoxelEditor (QMainWindow)                     │
│ ┌────────────────────────────────────────────┐│
│ │ CentralWidget (QStackedLayout)             ││
│ │ ┌──────────────────────────────────────┐  ││
│ │ │ GLWidget (QOpenGLWidget)             │  ││
│ │ │ ← 占据整个区域                        │  ││
│ │ └──────────────────────────────────────┘  ││
│ │ ┌────────────┐                            ││
│ │ │ EditorHUD  │ ← 半透明浮层               ││
│ │ │ (QWidget)  │   用 raise() 置顶          ││
│ │ │ 方块选择轮   │   用 setAttribute 穿透点击 ││
│ │ └────────────┘                            ││
│ └────────────────────────────────────────────┘│
│ ┌────────────────────────────────────────────┐│
│ │ QStatusBar — 当前方块/坐标/FPS             ││
│ └────────────────────────────────────────────┘│
└──────────────────────────────────────────────┘

8.2.2 实现

// VoxelEditor.cpp
VoxelEditor::VoxelEditor() {
    auto *central = new QWidget(this);
    setCentralWidget(central);

    // 底层:3D 视口
    m_glWidget = new GLWidget(central);
    m_glWidget->setGeometry(central->rect());

    // 上层:半透明 HUD
    m_hud = new EditorHUD(central);
    m_hud->setAttribute(Qt::WA_TransparentForMouseEvents, false);  // HUD 可以接收鼠标
    m_hud->raise();  // 确保在 GLWidget 之上

    // 状态栏
    m_statusBar = statusBar();
    m_statusBar->showMessage("就绪");

    // 焦点策略:GLWidget 默认接收键盘
    m_glWidget->setFocusPolicy(Qt::StrongFocus);
    m_glWidget->setFocus();
}

8.2.3 焦点抢夺与快捷键冲突

这个问题我花了很长时间才解决——当你把 QSlider、QPushButton 放在 OpenGL 视口上面时,会出现:

  • 鼠标拖拽 QSlider → GLWidget 的鼠标事件误触发 → 视角乱转
  • 按 WASD 移动 → 同时触发了 QPushButton 的键盘 shortcut → 莫名其妙的方块切换

解决方案:事件拦截 + 焦点锁

// EditorHUD.cpp
bool EditorHUD::event(QEvent *event) {
    if (event->type() == QEvent::MouseButtonPress ||
        event->type() == QEvent::MouseMove ||
        event->type() == QEvent::Wheel)
    {
        // 如果点击在 HUD 区域内 → 拦截,不传给 GLWidget
        auto *me = static_cast<QMouseEvent*>(event);
        if (rect().contains(me->pos())) {
            m_glWidget->setEnableInput(false);  // 暂停 GLWidget 的鼠标处理
            return QWidget::event(event);
        }
    } else if (event->type() == QEvent::MouseButtonRelease) {
        m_glWidget->setEnableInput(true);  // 恢复
    }
    return QWidget::event(event);
}
// GLWidget.cpp — 输入开关
void GLWidget::keyPressEvent(QKeyEvent *event) {
    if (!m_inputEnabled) return;  // HUD 交互时屏蔽

    switch (event->key()) {
        case Qt::Key_W: m_camera.moveForward(0.2f); break;
        case Qt::Key_S: m_camera.moveForward(-0.2f); break;
        case Qt::Key_A: m_camera.moveRight(-0.2f); break;
        case Qt::Key_D: m_camera.moveRight(0.2f); break;
        case Qt::Key_E: m_blockSelector->nextBlock(); break;  // E = 切换方块
        case Qt::Key_Q: m_blockSelector->prevBlock(); break;
    }
}

WASD 保留给移动,E/Q 用于方块切换——这是 Minecraft 的默认键位,玩家有肌肉记忆。


8.3 超越原版 — 光圈放置指示器

Minecraft 里鼠标指着方块时有一个黑色线框高亮。我们可以做得更漂亮——一个圆润的光圈。

// PlacementIndicator.h
class PlacementIndicator : public IRenderable {
    BlockHit m_target;
    Mesh m_ringMesh;  // 一个细圆环

public:
    void setTarget(const BlockHit &hit);

    void render(QOpenGLFunctions *gl,
                const QMatrix4x4 &view,
                const QMatrix4x4 &proj) override
    {
        // 1. 关闭面剔除——圆环两面都要画
        gl->glDisable(GL_CULL_FACE);

        // 2. 开启混合——半透明效果
        gl->glEnable(GL_BLEND);
        gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

        // 3. 画光圈(圆心在目标方块上面 0.05 格,直径 0.9 格)
        QMatrix4x4 model;
        model.translate(m_target.x + 0.5f,
                        m_target.y + 1.05f,
                        m_target.z + 0.5f);
        model.rotate(90.0f, 1, 0, 0);  // 水平放置
        model.scale(0.45f);

        // 用自定义着色器画发光圆环
        m_ringShader->bind();
        m_ringShader->setUniformValue("u_view", view);
        m_ringShader->setUniformValue("u_proj", proj);
        m_ringShader->setUniformValue("u_model", model);

        // 根据距离调整光圈透明度(太远就淡出)
        float dist = distance(m_target, m_camera.position());
        float alpha = std::max(0.0f, 1.0f - dist / 10.0f);
        m_ringShader->setUniformValue("u_alpha", alpha);

        m_ringMesh.draw();

        gl->glEnable(GL_CULL_FACE);
        gl->glDisable(GL_BLEND);
    }
};
// indicator.frag — 光圈着色器
uniform float u_alpha;
out vec4 fragColor;

void main() {
    // 用距离圆心的远近做一个发光衰减
    vec2 center = gl_FragCoord.xy - vec2(400, 300);  // 屏幕中心
    float dist = length(center) / 200.0;
    float glow = exp(-dist * 3.0) * u_alpha;

    fragColor = vec4(1.0, 1.0, 1.0, glow * 0.6);
}

细节的力量: 光圈不是简单的「画个圆」——它离玩家越远越透,远处方块不放光圈避免视觉噪点。圆心处最亮、边缘淡出,模拟发光效果。这个效果的代码不到 20 行,但给用户的感受是「这个编辑器是用心做的」。


8.4 章节收尾

graph TD
    QSS["全局 QSS<br/>体素风格配色"]
    SELECTOR["BlockSelector<br/>自绘方块选择轮"]
    HUD["EditorHUD<br/>半透明浮层"]
    FOCUS["焦点管理<br/>事件拦截"]
    INDICATOR["PlacementIndicator<br/>光圈放置指示器"]
    EDITOR["VoxelEditor<br/>完整的编辑器体验"]

    QSS --> SELECTOR
    QSS --> HUD
    SELECTOR --> EDITOR
    HUD --> FOCUS
    FOCUS --> EDITOR
    INDICATOR --> EDITOR

    style QSS fill:#4a90d9,color:#fff
    style SELECTOR fill:#50b86c,color:#fff
    style HUD fill:#e8913a,color:#fff
    style INDICATOR fill:#9b59b6,color:#fff
    style EDITOR fill:#e74c3c,color:#fff

架构师复盘

做编辑器 UI 时,我犯过一个方向性错误:试图把 Qt 的标准控件直接放在 OpenGL 上面不加任何处理。 结果 QSlider 拖不动(被 GLWidget 吃掉了鼠标事件),QComboBox 的弹出菜单出现在奇怪的位置。

后来发现,OpenGL 视口 + Qt 控件的组合需要的不是「更多控件」,而是「更少但更精的交互设计」:

  • 不用 QComboBox,自己画一个方块选择轮——体素风格、直接预览纹理、滚轮翻页
  • 不用 QPushButton 快捷键,用 WASD 移动、EQ 切换方块——玩家不需要离开键盘
  • 焦点管理用 m_inputEnabled 一个 bool 控制,而非到处 setEnabled(false)

教训: Qt 控件很好,但和 OpenGL 混用时,少即是多。自绘一个专为当前场景设计的控件,比调十个标准控件的兼容性好十倍。


系列文章:体素引擎从零构建 | 作者:Logic