前面七章搭出来的世界,交互只有「鼠标左键破坏、右键放置」。这离一个真正好用的编辑器还差得远。这一章用 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