Compare commits
3 Commits
a106c78bc8
...
0441a9d648
| Author | SHA1 | Date | |
|---|---|---|---|
| 0441a9d648 | |||
| fd508e4a68 | |||
| 63751d6f91 |
14
CLAUDE.md
14
CLAUDE.md
@ -12,7 +12,12 @@ GroveEngine is a C++17 hot-reload module system for game engines. It supports dy
|
|||||||
**Module-specific:**
|
**Module-specific:**
|
||||||
- **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles)
|
- **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles)
|
||||||
- **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad)
|
- **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad)
|
||||||
- **[UIModule README](modules/UIModule/README.md)** - User interface system (buttons, panels, scrolling, tooltips)
|
- **[UIModule README](modules/UIModule/README.md)** - User interface system overview
|
||||||
|
|
||||||
|
**UIModule Documentation (⚠️ READ BEFORE WORKING ON UI):**
|
||||||
|
- **[UI Widgets](docs/UI_WIDGETS.md)** - Widget properties, JSON configuration, custom widgets
|
||||||
|
- **[UI Topics](docs/UI_TOPICS.md)** - IIO topics reference and usage patterns
|
||||||
|
- **[UI Architecture](docs/UI_ARCHITECTURE.md)** - Threading model, limitations, design principles
|
||||||
- **[UI Rendering](docs/UI_RENDERING.md)** - Retained mode rendering architecture
|
- **[UI Rendering](docs/UI_RENDERING.md)** - Retained mode rendering architecture
|
||||||
|
|
||||||
## Available Modules
|
## Available Modules
|
||||||
@ -37,6 +42,10 @@ cmake --build build -j4
|
|||||||
# Run all tests (23+ tests)
|
# Run all tests (23+ tests)
|
||||||
cd build && ctest --output-on-failure
|
cd build && ctest --output-on-failure
|
||||||
|
|
||||||
|
# Run visual tests (IMPORTANT: always run from project root for correct asset paths)
|
||||||
|
./build/tests/test_ui_showcase # UI showcase with all widgets
|
||||||
|
./build/tests/test_renderer_showcase # Renderer showcase (sprites, text, particles)
|
||||||
|
|
||||||
# Build with ThreadSanitizer
|
# Build with ThreadSanitizer
|
||||||
cmake -DGROVE_ENABLE_TSAN=ON -B build-tsan
|
cmake -DGROVE_ENABLE_TSAN=ON -B build-tsan
|
||||||
cmake --build build-tsan -j4
|
cmake --build build-tsan -j4
|
||||||
@ -100,7 +109,8 @@ std::lock_guard lock2(mutex2); // DEADLOCK RISK
|
|||||||
### UIModule
|
### UIModule
|
||||||
- **UIRenderer**: Publishes render commands to BgfxRenderer via IIO (layer 1000+)
|
- **UIRenderer**: Publishes render commands to BgfxRenderer via IIO (layer 1000+)
|
||||||
- **Widgets**: UIButton, UIPanel, UILabel, UICheckbox, UISlider, UITextInput, UIProgressBar, UIImage, UIScrollPanel, UITooltip
|
- **Widgets**: UIButton, UIPanel, UILabel, UICheckbox, UISlider, UITextInput, UIProgressBar, UIImage, UIScrollPanel, UITooltip
|
||||||
- **IIO Topics**: Consumes `input:*`, publishes `ui:click`, `ui:action`, `ui:value_changed`, etc.
|
- **IIO Topics**: Consumes `input:*`, `ui:set_text`, `ui:set_visible`; publishes `ui:click`, `ui:action`, `ui:value_changed`, etc.
|
||||||
|
- **⚠️ Before modifying UI code:** Read [UI Architecture](docs/UI_ARCHITECTURE.md) for threading model, [UI Widgets](docs/UI_WIDGETS.md) for widget properties, [UI Topics](docs/UI_TOPICS.md) for IIO patterns
|
||||||
|
|
||||||
### InputModule
|
### InputModule
|
||||||
- **Backends**: SDLBackend (mouse, keyboard, gamepad Phase 2)
|
- **Backends**: SDLBackend (mouse, keyboard, gamepad Phase 2)
|
||||||
|
|||||||
BIN
assets/textures/IconDesigner.png
Normal file
BIN
assets/textures/IconDesigner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
367
docs/UI_ARCHITECTURE.md
Normal file
367
docs/UI_ARCHITECTURE.md
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
# UIModule - Architecture & Design
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
InputModule → IIO (input:*)
|
||||||
|
↓
|
||||||
|
UIModule
|
||||||
|
(Widget Tree)
|
||||||
|
↓
|
||||||
|
UIRenderer (publishes)
|
||||||
|
↓
|
||||||
|
IIO (render:*)
|
||||||
|
↓
|
||||||
|
BgfxRenderer
|
||||||
|
```
|
||||||
|
|
||||||
|
All communication happens via IIO topics - no direct module-to-module calls.
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
### No Direct Data Binding
|
||||||
|
|
||||||
|
UIModule **does not** have built-in data binding. Updates must flow through the game module:
|
||||||
|
|
||||||
|
```
|
||||||
|
Slider → ui:value_changed → Game Module → ui:set_text → Label
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **intentional** to maintain the IIO-based architecture where all communication goes through topics. The game module acts as the central coordinator.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```cpp
|
||||||
|
// Slider value changed
|
||||||
|
if (msg.topic == "ui:value_changed" && widgetId == "volume_slider") {
|
||||||
|
double value = msg.data->getDouble("value", 0);
|
||||||
|
setVolume(value);
|
||||||
|
|
||||||
|
// Update label (must go through game module)
|
||||||
|
auto updateMsg = std::make_unique<JsonDataNode>("set_text");
|
||||||
|
updateMsg->setString("id", "volume_label");
|
||||||
|
updateMsg->setString("text", "Volume: " + std::to_string((int)value) + "%");
|
||||||
|
m_io->publish("ui:set_text", std::move(updateMsg));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message Latency in Single-Threaded Mode
|
||||||
|
|
||||||
|
**Current test showcases:** ~16ms latency (1 frame @ 60 FPS)
|
||||||
|
|
||||||
|
**Cause:** Messages are queued until the next `pullMessage()` call in the game loop.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Single-threaded game loop (test showcase)
|
||||||
|
while(running) {
|
||||||
|
handleInput(); // UIModule publishes event
|
||||||
|
processUIEvents(); // Game receives event (next frame!)
|
||||||
|
update();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Run modules in separate threads (production architecture).
|
||||||
|
|
||||||
|
## Threading Model
|
||||||
|
|
||||||
|
### Current: Single-Threaded (Tests)
|
||||||
|
|
||||||
|
Test showcases run all modules in a single thread for simplicity:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void main() {
|
||||||
|
auto uiModule = loadModule("UIModule");
|
||||||
|
auto renderer = loadModule("BgfxRenderer");
|
||||||
|
|
||||||
|
while(running) {
|
||||||
|
// All in same thread - sequential execution
|
||||||
|
processInput();
|
||||||
|
uiModule->process(deltaTime);
|
||||||
|
renderer->process(deltaTime);
|
||||||
|
SDL_GL_SwapWindow(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Latency:** ~16ms (next frame polling)
|
||||||
|
|
||||||
|
### Production: Multi-Threaded
|
||||||
|
|
||||||
|
Each module runs in its own thread:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// UIModule thread @ 60 FPS
|
||||||
|
void uiThread() {
|
||||||
|
while(running) {
|
||||||
|
// Receive inputs from queue (filled by InputModule thread)
|
||||||
|
while(io->hasMessages()) {
|
||||||
|
handleMessage(io->pullMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime);
|
||||||
|
|
||||||
|
// Publish events (immediately queued to Game thread)
|
||||||
|
io->publish("ui:value_changed", msg);
|
||||||
|
|
||||||
|
sleep(16ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game thread @ 60 FPS
|
||||||
|
void gameThread() {
|
||||||
|
while(running) {
|
||||||
|
// Pull messages from queue (latency < 1ms)
|
||||||
|
while(io->hasMessages()) {
|
||||||
|
handleMessage(io->pullMessage()); // Already in queue!
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGameLogic(deltaTime);
|
||||||
|
sleep(16ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Latency:** < 1ms (just mutex lock + memcpy)
|
||||||
|
|
||||||
|
### IntraIO Message Delivery
|
||||||
|
|
||||||
|
IntraIO uses a **queue-based** system with push-on-publish, pull-on-consume:
|
||||||
|
|
||||||
|
1. **Module A publishes:** `io->publish("topic", msg)`
|
||||||
|
- Message immediately delivered to Module B's queue
|
||||||
|
- No batching delay (batch thread is for low-freq subscriptions only)
|
||||||
|
|
||||||
|
2. **Module B pulls:** `io->pullMessage()`
|
||||||
|
- Returns message from queue
|
||||||
|
- No network/serialization overhead
|
||||||
|
|
||||||
|
**With threading:** Messages are available in the queue immediately, so the next `pullMessage()` call retrieves them with minimal latency.
|
||||||
|
|
||||||
|
**Without threading:** All `pullMessage()` calls happen sequentially in the game loop, so messages wait until the next frame.
|
||||||
|
|
||||||
|
## Layer Management
|
||||||
|
|
||||||
|
UIModule uses layer-based rendering to ensure proper draw order:
|
||||||
|
|
||||||
|
- **Game sprites**: Layer 0-999
|
||||||
|
- **UI base layer**: 1000 (configurable via `baseLayer` config)
|
||||||
|
- **UI widgets**: baseLayer + widget index
|
||||||
|
- **Tooltips**: Highest layer (automatic)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
config.setInt("baseLayer", 1000); // UI renders above game
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hot-Reload Support
|
||||||
|
|
||||||
|
UIModule fully supports hot-reload with state preservation.
|
||||||
|
|
||||||
|
### State Preserved
|
||||||
|
- Widget properties (position, size, colors)
|
||||||
|
- Widget states (checkbox checked, slider values, text input content)
|
||||||
|
- Scroll positions
|
||||||
|
- Widget hierarchy
|
||||||
|
|
||||||
|
### State Not Preserved
|
||||||
|
- Transient animation states
|
||||||
|
- Mouse hover states (recalculated on next mouse move)
|
||||||
|
- Focus state (recalculated on next interaction)
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Extract State:**
|
||||||
|
```cpp
|
||||||
|
nlohmann::json UIModule::extractState() {
|
||||||
|
json state;
|
||||||
|
// Serialize all widget properties
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Reload Module:**
|
||||||
|
```cpp
|
||||||
|
moduleLoader.reload(); // Unload .dll, recompile, reload
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restore State:**
|
||||||
|
```cpp
|
||||||
|
void UIModule::restoreState(const nlohmann::json& state) {
|
||||||
|
// Restore widget properties from JSON
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Retained Mode Rendering
|
||||||
|
|
||||||
|
UIModule uses **retained mode** to optimize IIO traffic:
|
||||||
|
|
||||||
|
**Message Reduction:**
|
||||||
|
- Static UI (20 widgets, 0 changes): 100% reduction (0 messages/frame after registration)
|
||||||
|
- Mostly static (20 widgets, 3 changes): 85% reduction (3 vs 20 messages)
|
||||||
|
- Fully dynamic (20 widgets, 20 changes): 0% reduction (comparison overhead)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Widgets cache render state
|
||||||
|
- Compare against previous state each frame
|
||||||
|
- Only publish `render:*:update` if changed
|
||||||
|
|
||||||
|
See [UI_RENDERING.md](UI_RENDERING.md) for details.
|
||||||
|
|
||||||
|
### Target Performance
|
||||||
|
|
||||||
|
- **UI update:** < 1ms per frame
|
||||||
|
- **Render command generation:** < 0.5ms per frame
|
||||||
|
- **Message routing:** < 0.1ms per message
|
||||||
|
- **Widget count:** Up to 100+ widgets without performance issues
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
|
||||||
|
#### Data Binding (Optional)
|
||||||
|
|
||||||
|
Link widget properties to game variables with automatic sync:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "label",
|
||||||
|
"text": "${player.health}",
|
||||||
|
"bindTo": "player.health"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Will remain optional to preserve IIO architecture for those who prefer explicit control.
|
||||||
|
|
||||||
|
#### Animations
|
||||||
|
|
||||||
|
Tweening, fades, transitions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"animations": {
|
||||||
|
"enter": {"type": "fade", "duration": 0.3},
|
||||||
|
"exit": {"type": "slide", "direction": "left", "duration": 0.2}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flexible Layout
|
||||||
|
|
||||||
|
Anchors, constraints, flex, grid:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"anchor": "bottom-right",
|
||||||
|
"offset": {"x": -20, "y": -20}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"layout": "flex",
|
||||||
|
"flexDirection": "column",
|
||||||
|
"gap": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Drag & Drop
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"draggable": true,
|
||||||
|
"dragGroup": "inventory"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rich Text
|
||||||
|
|
||||||
|
Markdown/BBCode formatting:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "label",
|
||||||
|
"text": "**Bold** *italic* `code`",
|
||||||
|
"richText": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Themes
|
||||||
|
|
||||||
|
Swappable style sheets:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"theme": "dark",
|
||||||
|
"themeFile": "themes/dark.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9-Slice Sprites
|
||||||
|
|
||||||
|
Scalable sprite borders:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"sprite": "panel_border.png",
|
||||||
|
"sliceMode": "9-slice",
|
||||||
|
"sliceInsets": {"top": 8, "right": 8, "bottom": 8, "left": 8}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Input Validation
|
||||||
|
|
||||||
|
Regex patterns for text inputs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "textinput",
|
||||||
|
"validation": "^[a-zA-Z0-9]+$",
|
||||||
|
"errorMessage": "Alphanumeric only"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not Planned
|
||||||
|
|
||||||
|
These features violate core design principles and will **never** be added:
|
||||||
|
|
||||||
|
- ❌ **Direct widget-to-widget communication** - All communication must go through IIO topics
|
||||||
|
- ❌ **Embedded game logic in widgets** - Widgets are pure UI, game logic stays in game modules
|
||||||
|
- ❌ **Direct renderer access** - Widgets publish render commands via IIO, never call renderer directly
|
||||||
|
- ❌ **Direct input polling** - Widgets consume `input:*` topics, never poll input devices directly
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **IIO-First:** All communication via topics, no direct coupling
|
||||||
|
2. **Retained Mode:** Cache state, minimize IIO traffic
|
||||||
|
3. **Hot-Reload Safe:** Full state preservation across reloads
|
||||||
|
4. **Thread-Safe:** Designed for multi-threaded production use
|
||||||
|
5. **Module Independence:** UIModule never imports BgfxRenderer or InputModule headers
|
||||||
|
6. **Game Logic Separation:** Widgets are dumb views, game modules handle logic
|
||||||
|
|
||||||
|
## Integration with Other Modules
|
||||||
|
|
||||||
|
### With BgfxRenderer
|
||||||
|
|
||||||
|
UIModule → `render:sprite:*`, `render:text:*` → BgfxRenderer
|
||||||
|
|
||||||
|
No direct dependency. UIModule doesn't know BgfxRenderer exists.
|
||||||
|
|
||||||
|
### With InputModule
|
||||||
|
|
||||||
|
InputModule → `input:*` → UIModule
|
||||||
|
|
||||||
|
No direct dependency. UIModule doesn't know InputModule exists.
|
||||||
|
|
||||||
|
### With Game Module
|
||||||
|
|
||||||
|
Bidirectional via IIO:
|
||||||
|
- Game → `ui:set_text`, `ui:set_visible` → UIModule
|
||||||
|
- UIModule → `ui:action`, `ui:value_changed` → Game
|
||||||
|
|
||||||
|
Game module coordinates all interactions.
|
||||||
125
docs/UI_TOPICS.md
Normal file
125
docs/UI_TOPICS.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# UIModule - IIO Topics Reference
|
||||||
|
|
||||||
|
Complete reference of all IIO topics consumed and published by UIModule.
|
||||||
|
|
||||||
|
## Topics Consumed
|
||||||
|
|
||||||
|
### From InputModule
|
||||||
|
|
||||||
|
| Topic | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `input:mouse:move` | `{x, y}` | Mouse position |
|
||||||
|
| `input:mouse:button` | `{button, pressed, x, y}` | Mouse click |
|
||||||
|
| `input:mouse:wheel` | `{delta}` | Mouse wheel |
|
||||||
|
| `input:keyboard` | `{keyCode, pressed, char}` | Keyboard event |
|
||||||
|
|
||||||
|
### UI Control Commands
|
||||||
|
|
||||||
|
| Topic | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `ui:set_text` | `{id, text}` | Update label text dynamically |
|
||||||
|
| `ui:set_visible` | `{id, visible}` | Show/hide widget |
|
||||||
|
| `ui:set_value` | `{id, value}` | Set slider/progressbar value |
|
||||||
|
| `ui:load` | `{layoutPath}` | Load new UI layout from file |
|
||||||
|
|
||||||
|
## Topics Published
|
||||||
|
|
||||||
|
### UI Events
|
||||||
|
|
||||||
|
| Topic | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `ui:click` | `{widgetId, x, y}` | Widget clicked |
|
||||||
|
| `ui:action` | `{widgetId, action}` | Button action triggered |
|
||||||
|
| `ui:value_changed` | `{widgetId, value}` | Slider/checkbox/input changed |
|
||||||
|
| `ui:text_submitted` | `{widgetId, text}` | Text input submitted (Enter) |
|
||||||
|
| `ui:hover` | `{widgetId, enter}` | Mouse entered/left widget |
|
||||||
|
| `ui:scroll` | `{widgetId, scrollX, scrollY}` | Scroll panel scrolled |
|
||||||
|
|
||||||
|
### Rendering (Retained Mode)
|
||||||
|
|
||||||
|
| Topic | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite |
|
||||||
|
| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite |
|
||||||
|
| `render:sprite:remove` | `{renderId}` | Unregister sprite |
|
||||||
|
| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text |
|
||||||
|
| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text |
|
||||||
|
| `render:text:remove` | `{renderId}` | Unregister text |
|
||||||
|
|
||||||
|
### Rendering (Immediate Mode - Legacy)
|
||||||
|
|
||||||
|
| Topic | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `render:sprite` | `{x, y, w, h, color, layer, ...}` | Ephemeral sprite (1 frame) |
|
||||||
|
| `render:text` | `{x, y, text, fontSize, color, layer}` | Ephemeral text (1 frame) |
|
||||||
|
|
||||||
|
See [UI Rendering Documentation](UI_RENDERING.md) for details on retained vs immediate mode.
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Handling UI Events
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Subscribe to UI events
|
||||||
|
gameIO->subscribe("ui:action");
|
||||||
|
gameIO->subscribe("ui:value_changed");
|
||||||
|
|
||||||
|
// In game loop
|
||||||
|
while (m_io->hasMessages() > 0) {
|
||||||
|
auto msg = m_io->pullMessage();
|
||||||
|
|
||||||
|
if (msg.topic == "ui:action") {
|
||||||
|
std::string action = msg.data->getString("action", "");
|
||||||
|
if (action == "start_game") {
|
||||||
|
startGame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.topic == "ui:value_changed") {
|
||||||
|
std::string widgetId = msg.data->getString("widgetId", "");
|
||||||
|
if (widgetId == "volume_slider") {
|
||||||
|
double value = msg.data->getDouble("value", 50.0);
|
||||||
|
setVolume(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating UI Dynamically
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Update label text
|
||||||
|
auto msg = std::make_unique<JsonDataNode>("set_text");
|
||||||
|
msg->setString("id", "score_label");
|
||||||
|
msg->setString("text", "Score: " + std::to_string(score));
|
||||||
|
m_io->publish("ui:set_text", std::move(msg));
|
||||||
|
|
||||||
|
// Hide/show widget
|
||||||
|
auto msg = std::make_unique<JsonDataNode>("set_visible");
|
||||||
|
msg->setString("id", "loading_panel");
|
||||||
|
msg->setBool("visible", false);
|
||||||
|
m_io->publish("ui:set_visible", std::move(msg));
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
auto msg = std::make_unique<JsonDataNode>("set_value");
|
||||||
|
msg->setString("id", "health_bar");
|
||||||
|
msg->setDouble("value", 0.75); // 75%
|
||||||
|
m_io->publish("ui:set_value", std::move(msg));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slider + Label Pattern
|
||||||
|
|
||||||
|
Common pattern: update a label when a slider changes.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (msg.topic == "ui:value_changed" && widgetId == "volume_slider") {
|
||||||
|
double value = msg.data->getDouble("value", 50.0);
|
||||||
|
setVolume(value);
|
||||||
|
|
||||||
|
// Update label to show current value
|
||||||
|
auto updateMsg = std::make_unique<JsonDataNode>("set_text");
|
||||||
|
updateMsg->setString("id", "volume_label");
|
||||||
|
updateMsg->setString("text", "Volume: " + std::to_string((int)value) + "%");
|
||||||
|
m_io->publish("ui:set_text", std::move(updateMsg));
|
||||||
|
}
|
||||||
|
```
|
||||||
376
docs/UI_WIDGETS.md
Normal file
376
docs/UI_WIDGETS.md
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
# UIModule - Widget Reference
|
||||||
|
|
||||||
|
Complete reference for all available widgets and their properties.
|
||||||
|
|
||||||
|
## Widget Overview
|
||||||
|
|
||||||
|
| Widget | Purpose | Events |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| **UIButton** | Clickable button | `ui:click`, `ui:action` |
|
||||||
|
| **UILabel** | Static/dynamic text | - |
|
||||||
|
| **UIPanel** | Container widget | - |
|
||||||
|
| **UICheckbox** | Toggle checkbox | `ui:value_changed` |
|
||||||
|
| **UISlider** | Value slider | `ui:value_changed` |
|
||||||
|
| **UITextInput** | Text entry field | `ui:value_changed`, `ui:text_submitted` |
|
||||||
|
| **UIProgressBar** | Progress indicator | - |
|
||||||
|
| **UIImage** | Sprite/texture display | - |
|
||||||
|
| **UIScrollPanel** | Scrollable container | `ui:scroll` |
|
||||||
|
| **UITooltip** | Hover tooltip | - |
|
||||||
|
|
||||||
|
## Common Properties
|
||||||
|
|
||||||
|
All widgets support these base properties:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "WidgetType",
|
||||||
|
"id": "unique_id",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 100,
|
||||||
|
"height": 100,
|
||||||
|
"visible": true,
|
||||||
|
"tooltip": "Optional tooltip text"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UIButton
|
||||||
|
|
||||||
|
Clickable button with hover/press states.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "my_button",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 200,
|
||||||
|
"height": 50,
|
||||||
|
"text": "Click Me",
|
||||||
|
"onClick": "button_action",
|
||||||
|
"style": {
|
||||||
|
"normal": {
|
||||||
|
"bgColor": "0x0984e3FF",
|
||||||
|
"textColor": "0xFFFFFFFF",
|
||||||
|
"textureId": 0
|
||||||
|
},
|
||||||
|
"hover": {
|
||||||
|
"bgColor": "0x74b9ffFF",
|
||||||
|
"textColor": "0xFFFFFFFF"
|
||||||
|
},
|
||||||
|
"pressed": {
|
||||||
|
"bgColor": "0x0652a1FF",
|
||||||
|
"textColor": "0xFFFFFFFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `text` - Button label text
|
||||||
|
- `onClick` - Action name published to `ui:action`
|
||||||
|
- `style` - Visual states (normal, hover, pressed)
|
||||||
|
- `bgColor` - Background color (hex RGBA)
|
||||||
|
- `textColor` - Text color (hex RGBA)
|
||||||
|
- `textureId` - Sprite texture ID (0 = solid color)
|
||||||
|
|
||||||
|
**Events:**
|
||||||
|
- `ui:click` - `{widgetId, x, y}`
|
||||||
|
- `ui:action` - `{widgetId, action}` where action = onClick value
|
||||||
|
|
||||||
|
## UILabel
|
||||||
|
|
||||||
|
Static or dynamic text display.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "label",
|
||||||
|
"id": "my_label",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 300,
|
||||||
|
"height": 50,
|
||||||
|
"text": "Hello World",
|
||||||
|
"style": {
|
||||||
|
"fontSize": 24,
|
||||||
|
"color": "0xFFFFFFFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `text` - Label text (can be updated via `ui:set_text`)
|
||||||
|
- `style.fontSize` - Font size in pixels
|
||||||
|
- `style.color` - Text color (hex RGBA)
|
||||||
|
|
||||||
|
**Dynamic Updates:**
|
||||||
|
```cpp
|
||||||
|
auto msg = std::make_unique<JsonDataNode>("set_text");
|
||||||
|
msg->setString("id", "my_label");
|
||||||
|
msg->setString("text", "New Text");
|
||||||
|
m_io->publish("ui:set_text", std::move(msg));
|
||||||
|
```
|
||||||
|
|
||||||
|
## UIPanel
|
||||||
|
|
||||||
|
Container widget with background color.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"id": "my_panel",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 400,
|
||||||
|
"height": 300,
|
||||||
|
"style": {
|
||||||
|
"bgColor": "0x2d3436FF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `style.bgColor` - Background color (hex RGBA, use `0x00000000` for transparent)
|
||||||
|
|
||||||
|
## UICheckbox
|
||||||
|
|
||||||
|
Toggle checkbox with check state.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "enable_vsync",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 24,
|
||||||
|
"height": 24,
|
||||||
|
"checked": true,
|
||||||
|
"text": "Enable VSync"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `checked` - Initial checked state
|
||||||
|
- `text` - Optional label text next to checkbox
|
||||||
|
|
||||||
|
**Events:**
|
||||||
|
- `ui:value_changed` - `{widgetId, checked}`
|
||||||
|
|
||||||
|
## UISlider
|
||||||
|
|
||||||
|
Horizontal or vertical value slider.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "slider",
|
||||||
|
"id": "volume_slider",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 300,
|
||||||
|
"height": 24,
|
||||||
|
"min": 0.0,
|
||||||
|
"max": 100.0,
|
||||||
|
"value": 50.0,
|
||||||
|
"orientation": "horizontal"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `min` - Minimum value
|
||||||
|
- `max` - Maximum value
|
||||||
|
- `value` - Current value
|
||||||
|
- `orientation` - "horizontal" or "vertical"
|
||||||
|
|
||||||
|
**Events:**
|
||||||
|
- `ui:value_changed` - `{widgetId, value, min, max}`
|
||||||
|
|
||||||
|
## UITextInput
|
||||||
|
|
||||||
|
Text entry field with cursor and focus state.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "textinput",
|
||||||
|
"id": "player_name",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 300,
|
||||||
|
"height": 40,
|
||||||
|
"text": "",
|
||||||
|
"placeholder": "Enter name...",
|
||||||
|
"maxLength": 32,
|
||||||
|
"style": {
|
||||||
|
"fontSize": 20,
|
||||||
|
"textColor": "0xFFFFFFFF",
|
||||||
|
"bgColor": "0x34495eFF",
|
||||||
|
"borderColor": "0x666666FF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `text` - Initial text
|
||||||
|
- `placeholder` - Placeholder text when empty
|
||||||
|
- `maxLength` - Maximum character limit
|
||||||
|
- `style.fontSize` - Font size
|
||||||
|
- `style.textColor` - Text color
|
||||||
|
- `style.bgColor` - Background color
|
||||||
|
- `style.borderColor` - Border color (changes to blue when focused)
|
||||||
|
|
||||||
|
**Events:**
|
||||||
|
- `ui:value_changed` - `{widgetId, text}` - on each character change
|
||||||
|
- `ui:text_submitted` - `{widgetId, text}` - on Enter key
|
||||||
|
|
||||||
|
## UIProgressBar
|
||||||
|
|
||||||
|
Progress indicator (0.0 to 1.0).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "progressbar",
|
||||||
|
"id": "loading_bar",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 400,
|
||||||
|
"height": 30,
|
||||||
|
"value": 0.65,
|
||||||
|
"style": {
|
||||||
|
"bgColor": "0x34495eFF",
|
||||||
|
"fillColor": "0x2ecc71FF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `value` - Progress value (0.0 = empty, 1.0 = full)
|
||||||
|
- `style.bgColor` - Background color
|
||||||
|
- `style.fillColor` - Fill color
|
||||||
|
|
||||||
|
**Dynamic Updates:**
|
||||||
|
```cpp
|
||||||
|
auto msg = std::make_unique<JsonDataNode>("set_value");
|
||||||
|
msg->setString("id", "loading_bar");
|
||||||
|
msg->setDouble("value", 0.75); // 75%
|
||||||
|
m_io->publish("ui:set_value", std::move(msg));
|
||||||
|
```
|
||||||
|
|
||||||
|
## UIImage
|
||||||
|
|
||||||
|
Display a sprite/texture.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"id": "logo",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 200,
|
||||||
|
"height": 200,
|
||||||
|
"textureId": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `textureId` - Texture ID from BgfxRenderer
|
||||||
|
|
||||||
|
## UIScrollPanel
|
||||||
|
|
||||||
|
Scrollable container with vertical scrollbar.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "scrollpanel",
|
||||||
|
"id": "inventory_panel",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 400,
|
||||||
|
"height": 600,
|
||||||
|
"contentHeight": 1200,
|
||||||
|
"scrollY": 0.0,
|
||||||
|
"scrollbarWidth": 20,
|
||||||
|
"style": {
|
||||||
|
"bgColor": "0x2d3436FF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `contentHeight` - Total height of scrollable content
|
||||||
|
- `scrollY` - Initial scroll position (0.0 = top)
|
||||||
|
- `scrollbarWidth` - Width of scrollbar in pixels
|
||||||
|
- `style.bgColor` - Background color
|
||||||
|
|
||||||
|
**Events:**
|
||||||
|
- `ui:scroll` - `{widgetId, scrollY}`
|
||||||
|
|
||||||
|
## UITooltip
|
||||||
|
|
||||||
|
Hover tooltip (managed automatically by UIModule).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tooltip",
|
||||||
|
"id": "help_tooltip",
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 200,
|
||||||
|
"height": 60,
|
||||||
|
"text": "This is a helpful tooltip",
|
||||||
|
"visible": false,
|
||||||
|
"style": {
|
||||||
|
"fontSize": 14,
|
||||||
|
"bgColor": "0x2c3e50FF",
|
||||||
|
"textColor": "0xFFFFFFFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Tooltips are automatically shown when `tooltip` property is set on any widget:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "save_button",
|
||||||
|
"tooltip": "Save your progress",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Custom Widgets
|
||||||
|
|
||||||
|
1. Create `Widgets/MyWidget.h/.cpp`
|
||||||
|
2. Inherit from `UIWidget`
|
||||||
|
3. Implement required methods:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class MyWidget : public UIWidget {
|
||||||
|
public:
|
||||||
|
void update(UIContext& ctx, float deltaTime) override;
|
||||||
|
void render(UIRenderer& renderer) override;
|
||||||
|
std::string getType() const override { return "mywidget"; }
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
bool onMouseButton(int button, bool pressed, float x, float y) override;
|
||||||
|
void onMouseMove(float x, float y) override;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Register in `UITree::createWidget()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (type == "mywidget") {
|
||||||
|
auto widget = std::make_unique<MyWidget>();
|
||||||
|
// ... configure from JSON
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Use in JSON layouts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "mywidget",
|
||||||
|
"id": "custom1",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -59,7 +59,7 @@ void SpritePass::setup(rhi::IRHIDevice& device) {
|
|||||||
// Create texture sampler uniform (must match shader: s_texColor)
|
// Create texture sampler uniform (must match shader: s_texColor)
|
||||||
m_textureSampler = device.createUniform("s_texColor", 1);
|
m_textureSampler = device.createUniform("s_texColor", 1);
|
||||||
|
|
||||||
// Create default white 4x4 texture (used when no texture is bound)
|
// Create default white 4x4 texture (restored to white)
|
||||||
// Some drivers have issues with 1x1 textures
|
// Some drivers have issues with 1x1 textures
|
||||||
uint32_t whitePixels[16];
|
uint32_t whitePixels[16];
|
||||||
for (int i = 0; i < 16; ++i) whitePixels[i] = 0xFFFFFFFF; // RGBA white
|
for (int i = 0; i < 16; ++i) whitePixels[i] = 0xFFFFFFFF; // RGBA white
|
||||||
@ -98,15 +98,14 @@ void SpritePass::flushBatch(rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd,
|
|||||||
void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) {
|
void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) {
|
||||||
if (frame.spriteCount == 0) return;
|
if (frame.spriteCount == 0) return;
|
||||||
|
|
||||||
// Set render state ONCE (like TextPass does)
|
// Prepare render state (will be set before each batch)
|
||||||
rhi::RenderState state;
|
rhi::RenderState state;
|
||||||
state.blend = rhi::BlendMode::Alpha;
|
state.blend = rhi::BlendMode::Alpha;
|
||||||
state.cull = rhi::CullMode::None;
|
state.cull = rhi::CullMode::None;
|
||||||
state.depthTest = false;
|
state.depthTest = false;
|
||||||
state.depthWrite = false;
|
state.depthWrite = false;
|
||||||
cmd.setState(state);
|
|
||||||
|
|
||||||
// Sort sprites by layer for correct draw order
|
// Sort sprites by layer first (for correct draw order), then by texture (for batching)
|
||||||
m_sortedIndices.clear();
|
m_sortedIndices.clear();
|
||||||
m_sortedIndices.reserve(frame.spriteCount);
|
m_sortedIndices.reserve(frame.spriteCount);
|
||||||
for (size_t i = 0; i < frame.spriteCount; ++i) {
|
for (size_t i = 0; i < frame.spriteCount; ++i) {
|
||||||
@ -114,27 +113,133 @@ void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi:
|
|||||||
}
|
}
|
||||||
std::sort(m_sortedIndices.begin(), m_sortedIndices.end(),
|
std::sort(m_sortedIndices.begin(), m_sortedIndices.end(),
|
||||||
[&frame](uint32_t a, uint32_t b) {
|
[&frame](uint32_t a, uint32_t b) {
|
||||||
|
// Sort by layer first, then by textureId for batching
|
||||||
|
if (frame.sprites[a].layer != frame.sprites[b].layer) {
|
||||||
return frame.sprites[a].layer < frame.sprites[b].layer;
|
return frame.sprites[a].layer < frame.sprites[b].layer;
|
||||||
|
}
|
||||||
|
return frame.sprites[a].textureId < frame.sprites[b].textureId;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy sorted sprites to temporary buffer (like TextPass does with glyphs)
|
// Batch sprites by texture
|
||||||
std::vector<SpriteInstance> sortedSprites;
|
std::vector<SpriteInstance> batchSprites;
|
||||||
sortedSprites.reserve(frame.spriteCount);
|
batchSprites.reserve(frame.spriteCount);
|
||||||
for (uint32_t idx : m_sortedIndices) {
|
|
||||||
sortedSprites.push_back(frame.sprites[idx]);
|
uint16_t currentTextureId = 0;
|
||||||
|
bool firstBatch = true;
|
||||||
|
|
||||||
|
static int spriteLogCount = 0;
|
||||||
|
for (size_t i = 0; i < m_sortedIndices.size(); ++i) {
|
||||||
|
uint32_t idx = m_sortedIndices[i];
|
||||||
|
const SpriteInstance& sprite = frame.sprites[idx];
|
||||||
|
uint16_t spriteTexId = static_cast<uint16_t>(sprite.textureId);
|
||||||
|
|
||||||
|
// Log first few textured sprites
|
||||||
|
if (spriteLogCount < 10 && spriteTexId > 0) {
|
||||||
|
spdlog::info("🎨 [SpritePass] Processing sprite #{}: textureId={}, pos=({:.1f},{:.1f}), scale={}x{}, layer={}",
|
||||||
|
spriteLogCount++, spriteTexId, sprite.x, sprite.y, sprite.scaleX, sprite.scaleY, (int)sprite.layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update dynamic instance buffer with ALL sprites (like TextPass)
|
// Start new batch if texture changes
|
||||||
device.updateBuffer(m_instanceBuffer, sortedSprites.data(),
|
if (!firstBatch && spriteTexId != currentTextureId) {
|
||||||
static_cast<uint32_t>(sortedSprites.size() * sizeof(SpriteInstance)));
|
// Flush previous batch using TRANSIENT BUFFER (one per batch)
|
||||||
|
uint32_t batchSize = static_cast<uint32_t>(batchSprites.size());
|
||||||
|
rhi::TransientInstanceBuffer transientBuffer = device.allocTransientInstanceBuffer(batchSize);
|
||||||
|
|
||||||
|
// CRITICAL: Set render state before EACH batch (consumed by submit)
|
||||||
|
cmd.setState(state);
|
||||||
|
|
||||||
|
// Get texture handle from ResourceCache
|
||||||
|
rhi::TextureHandle texHandle = m_defaultTexture;
|
||||||
|
if (m_resourceCache && currentTextureId > 0) {
|
||||||
|
auto cachedTex = m_resourceCache->getTextureById(currentTextureId);
|
||||||
|
if (cachedTex.isValid()) {
|
||||||
|
texHandle = cachedTex;
|
||||||
|
static int batchNum = 0;
|
||||||
|
spdlog::info("[Batch #{}] SpritePass flushing batch: textureId={}, handle={}, size={}",
|
||||||
|
batchNum++, currentTextureId, texHandle.id, batchSprites.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transientBuffer.isValid()) {
|
||||||
|
// Copy sprite data to transient buffer (frame-local, won't be overwritten)
|
||||||
|
std::memcpy(transientBuffer.data, batchSprites.data(), batchSize * sizeof(SpriteInstance));
|
||||||
|
|
||||||
// Set buffers and draw ALL sprites in ONE call (like TextPass)
|
|
||||||
cmd.setVertexBuffer(m_quadVB);
|
cmd.setVertexBuffer(m_quadVB);
|
||||||
cmd.setIndexBuffer(m_quadIB);
|
cmd.setIndexBuffer(m_quadIB);
|
||||||
cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast<uint32_t>(sortedSprites.size()));
|
cmd.setTransientInstanceBuffer(transientBuffer, 0, batchSize);
|
||||||
cmd.setTexture(0, m_defaultTexture, m_textureSampler);
|
cmd.setTexture(0, texHandle, m_textureSampler);
|
||||||
cmd.drawInstanced(6, static_cast<uint32_t>(sortedSprites.size()));
|
cmd.drawInstanced(6, batchSize);
|
||||||
cmd.submit(0, m_shader, 0);
|
cmd.submit(0, m_shader, 0);
|
||||||
|
} else {
|
||||||
|
// Fallback to dynamic buffer (single batch limitation - data will be overwritten!)
|
||||||
|
device.updateBuffer(m_instanceBuffer, batchSprites.data(), batchSize * sizeof(SpriteInstance));
|
||||||
|
|
||||||
|
cmd.setVertexBuffer(m_quadVB);
|
||||||
|
cmd.setIndexBuffer(m_quadIB);
|
||||||
|
cmd.setInstanceBuffer(m_instanceBuffer, 0, batchSize);
|
||||||
|
cmd.setTexture(0, texHandle, m_textureSampler);
|
||||||
|
cmd.drawInstanced(6, batchSize);
|
||||||
|
cmd.submit(0, m_shader, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new batch
|
||||||
|
batchSprites.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
batchSprites.push_back(sprite);
|
||||||
|
currentTextureId = spriteTexId;
|
||||||
|
firstBatch = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush final batch
|
||||||
|
if (!batchSprites.empty()) {
|
||||||
|
// Use TRANSIENT BUFFER for final batch too
|
||||||
|
uint32_t batchSize = static_cast<uint32_t>(batchSprites.size());
|
||||||
|
rhi::TransientInstanceBuffer transientBuffer = device.allocTransientInstanceBuffer(batchSize);
|
||||||
|
|
||||||
|
// CRITICAL: Set render state before EACH batch (consumed by submit)
|
||||||
|
cmd.setState(state);
|
||||||
|
|
||||||
|
// Get texture handle from ResourceCache
|
||||||
|
rhi::TextureHandle texHandle = m_defaultTexture;
|
||||||
|
if (m_resourceCache && currentTextureId > 0) {
|
||||||
|
auto cachedTex = m_resourceCache->getTextureById(currentTextureId);
|
||||||
|
if (cachedTex.isValid()) {
|
||||||
|
texHandle = cachedTex;
|
||||||
|
static int finalBatchNum = 0;
|
||||||
|
spdlog::info("[Final Batch #{}] SpritePass flushing final batch: textureId={}, handle={}, size={}",
|
||||||
|
finalBatchNum++, currentTextureId, texHandle.id, batchSprites.size());
|
||||||
|
} else {
|
||||||
|
static bool warnLogged = false;
|
||||||
|
if (!warnLogged) {
|
||||||
|
spdlog::warn("SpritePass: Texture ID {} not found in cache, using default", currentTextureId);
|
||||||
|
warnLogged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transientBuffer.isValid()) {
|
||||||
|
// Copy sprite data to transient buffer (frame-local, won't be overwritten)
|
||||||
|
std::memcpy(transientBuffer.data, batchSprites.data(), batchSize * sizeof(SpriteInstance));
|
||||||
|
|
||||||
|
cmd.setVertexBuffer(m_quadVB);
|
||||||
|
cmd.setIndexBuffer(m_quadIB);
|
||||||
|
cmd.setTransientInstanceBuffer(transientBuffer, 0, batchSize);
|
||||||
|
cmd.setTexture(0, texHandle, m_textureSampler);
|
||||||
|
cmd.drawInstanced(6, batchSize);
|
||||||
|
cmd.submit(0, m_shader, 0);
|
||||||
|
} else {
|
||||||
|
// Fallback to dynamic buffer (single batch limitation - data will be overwritten!)
|
||||||
|
device.updateBuffer(m_instanceBuffer, batchSprites.data(), batchSize * sizeof(SpriteInstance));
|
||||||
|
|
||||||
|
cmd.setVertexBuffer(m_quadVB);
|
||||||
|
cmd.setIndexBuffer(m_quadIB);
|
||||||
|
cmd.setInstanceBuffer(m_instanceBuffer, 0, batchSize);
|
||||||
|
cmd.setTexture(0, texHandle, m_textureSampler);
|
||||||
|
cmd.drawInstanced(6, batchSize);
|
||||||
|
cmd.submit(0, m_shader, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace grove
|
} // namespace grove
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include "../RHI/RHIDevice.h"
|
#include "../RHI/RHIDevice.h"
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <shared_mutex>
|
#include <shared_mutex>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
|
|
||||||
@ -26,8 +27,19 @@ rhi::ShaderHandle ResourceCache::getShader(const std::string& name) const {
|
|||||||
|
|
||||||
rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const {
|
rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const {
|
||||||
std::shared_lock lock(m_mutex);
|
std::shared_lock lock(m_mutex);
|
||||||
|
static bool logged = false;
|
||||||
|
if (!logged && id > 0) {
|
||||||
|
spdlog::info("ResourceCache::getTextureById({}) - cache size: {}", id, m_textureById.size());
|
||||||
|
logged = true;
|
||||||
|
}
|
||||||
if (id < m_textureById.size()) {
|
if (id < m_textureById.size()) {
|
||||||
return m_textureById[id];
|
auto handle = m_textureById[id];
|
||||||
|
static bool handleLogged = false;
|
||||||
|
if (!handleLogged && id > 0) {
|
||||||
|
spdlog::info(" -> Found handle with id: {}, valid: {}", handle.id, handle.isValid());
|
||||||
|
handleLogged = true;
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
}
|
}
|
||||||
return rhi::TextureHandle{}; // Invalid handle
|
return rhi::TextureHandle{}; // Invalid handle
|
||||||
}
|
}
|
||||||
@ -41,12 +53,37 @@ uint16_t ResourceCache::getTextureId(const std::string& path) const {
|
|||||||
return 0; // Invalid ID
|
return 0; // Invalid ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint16_t ResourceCache::registerTexture(rhi::TextureHandle handle, const std::string& name) {
|
||||||
|
if (!handle.isValid()) {
|
||||||
|
return 0; // Invalid handle
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_lock lock(m_mutex);
|
||||||
|
|
||||||
|
// Assign new ID
|
||||||
|
uint16_t newId = static_cast<uint16_t>(m_textureById.size());
|
||||||
|
if (newId == 0) {
|
||||||
|
// Reserve index 0 as invalid/default
|
||||||
|
m_textureById.push_back(rhi::TextureHandle{});
|
||||||
|
newId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_textureById.push_back(handle);
|
||||||
|
if (!name.empty()) {
|
||||||
|
m_pathToTextureId[name] = newId;
|
||||||
|
m_textures[name] = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::string& path) {
|
uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::string& path) {
|
||||||
// Check if already loaded
|
// Check if already loaded
|
||||||
{
|
{
|
||||||
std::shared_lock lock(m_mutex);
|
std::shared_lock lock(m_mutex);
|
||||||
auto it = m_pathToTextureId.find(path);
|
auto it = m_pathToTextureId.find(path);
|
||||||
if (it != m_pathToTextureId.end()) {
|
if (it != m_pathToTextureId.end()) {
|
||||||
|
spdlog::info("📋 ResourceCache: Texture '{}' already loaded with ID {}", path, it->second);
|
||||||
return it->second;
|
return it->second;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,6 +92,7 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st
|
|||||||
auto result = TextureLoader::loadFromFile(device, path);
|
auto result = TextureLoader::loadFromFile(device, path);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
spdlog::error("❌ ResourceCache: FAILED to load texture '{}': {}", path, result.error);
|
||||||
return 0; // Invalid ID
|
return 0; // Invalid ID
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +120,8 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st
|
|||||||
m_pathToTextureId[path] = newId;
|
m_pathToTextureId[path] = newId;
|
||||||
m_textures[path] = result.handle;
|
m_textures[path] = result.handle;
|
||||||
|
|
||||||
|
spdlog::info("✅ ResourceCache: Texture '{}' registered with ID {} (handle={})", path, newId, result.handle.id);
|
||||||
|
|
||||||
return newId;
|
return newId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,16 +5,20 @@
|
|||||||
#include <stb/stb_image.h>
|
#include <stb/stb_image.h>
|
||||||
|
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
|
|
||||||
TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, const std::string& path) {
|
TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, const std::string& path) {
|
||||||
LoadResult result;
|
LoadResult result;
|
||||||
|
|
||||||
|
spdlog::info("📂 TextureLoader: Loading texture from '{}'", path);
|
||||||
|
|
||||||
// Read file into memory
|
// Read file into memory
|
||||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
result.error = "Failed to open file: " + path;
|
result.error = "Failed to open file: " + path;
|
||||||
|
spdlog::error("❌ TextureLoader: FAILED to open file '{}'", path);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,10 +28,21 @@ TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, c
|
|||||||
std::vector<uint8_t> buffer(static_cast<size_t>(size));
|
std::vector<uint8_t> buffer(static_cast<size_t>(size));
|
||||||
if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
|
if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
|
||||||
result.error = "Failed to read file: " + path;
|
result.error = "Failed to read file: " + path;
|
||||||
|
spdlog::error("❌ TextureLoader: FAILED to read file '{}'", path);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadFromMemory(device, buffer.data(), buffer.size());
|
spdlog::info("✅ TextureLoader: File '{}' read successfully ({} bytes)", path, size);
|
||||||
|
auto loadResult = loadFromMemory(device, buffer.data(), buffer.size());
|
||||||
|
|
||||||
|
if (loadResult.success) {
|
||||||
|
spdlog::info("✅ TextureLoader: Texture '{}' loaded successfully ({}x{}, handle={})",
|
||||||
|
path, loadResult.width, loadResult.height, loadResult.handle.id);
|
||||||
|
} else {
|
||||||
|
spdlog::error("❌ TextureLoader: FAILED to load texture '{}': {}", path, loadResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device, const uint8_t* data, size_t size) {
|
TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device, const uint8_t* data, size_t size) {
|
||||||
@ -44,6 +59,17 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log decoded image info
|
||||||
|
spdlog::info("TextureLoader: Decoded image {}x{} (original {} channels, converted to RGBA)",
|
||||||
|
width, height, channels);
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
spdlog::info(" First pixel: R={}, G={}, B={}, A={}",
|
||||||
|
pixels[0], pixels[1], pixels[2], pixels[3]);
|
||||||
|
spdlog::info(" Last pixel: R={}, G={}, B={}, A={}",
|
||||||
|
pixels[(width*height-1)*4 + 0], pixels[(width*height-1)*4 + 1],
|
||||||
|
pixels[(width*height-1)*4 + 2], pixels[(width*height-1)*4 + 3]);
|
||||||
|
}
|
||||||
|
|
||||||
// Create texture via RHI
|
// Create texture via RHI
|
||||||
rhi::TextureDesc desc;
|
rhi::TextureDesc desc;
|
||||||
desc.width = static_cast<uint16_t>(width);
|
desc.width = static_cast<uint16_t>(width);
|
||||||
@ -57,8 +83,11 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device,
|
|||||||
result.height = desc.height;
|
result.height = desc.height;
|
||||||
result.success = result.handle.isValid();
|
result.success = result.handle.isValid();
|
||||||
|
|
||||||
if (!result.success) {
|
if (result.success) {
|
||||||
|
spdlog::info("✅ TextureLoader: GPU texture created successfully (handle={})", result.handle.id);
|
||||||
|
} else {
|
||||||
result.error = "Failed to create GPU texture";
|
result.error = "Failed to create GPU texture";
|
||||||
|
spdlog::error("❌ TextureLoader: FAILED to create GPU texture (handle invalid)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free stb_image memory
|
// Free stb_image memory
|
||||||
|
|||||||
@ -28,9 +28,11 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
|
|||||||
// Route message based on topic
|
// Route message based on topic
|
||||||
// Retained mode (new) - sprites
|
// Retained mode (new) - sprites
|
||||||
if (msg.topic == "render:sprite:add") {
|
if (msg.topic == "render:sprite:add") {
|
||||||
|
spdlog::info("✅ RETAINED MODE: render:sprite:add received");
|
||||||
parseSpriteAdd(*msg.data);
|
parseSpriteAdd(*msg.data);
|
||||||
}
|
}
|
||||||
else if (msg.topic == "render:sprite:update") {
|
else if (msg.topic == "render:sprite:update") {
|
||||||
|
spdlog::info("✅ RETAINED MODE: render:sprite:update received");
|
||||||
parseSpriteUpdate(*msg.data);
|
parseSpriteUpdate(*msg.data);
|
||||||
}
|
}
|
||||||
else if (msg.topic == "render:sprite:remove") {
|
else if (msg.topic == "render:sprite:remove") {
|
||||||
@ -48,6 +50,7 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
|
|||||||
}
|
}
|
||||||
// Ephemeral mode (legacy)
|
// Ephemeral mode (legacy)
|
||||||
else if (msg.topic == "render:sprite") {
|
else if (msg.topic == "render:sprite") {
|
||||||
|
spdlog::info("⚠️ EPHEMERAL MODE: render:sprite received (should not happen in retained mode!)");
|
||||||
parseSprite(*msg.data);
|
parseSprite(*msg.data);
|
||||||
}
|
}
|
||||||
else if (msg.topic == "render:sprite:batch") {
|
else if (msg.topic == "render:sprite:batch") {
|
||||||
@ -487,6 +490,9 @@ void SceneCollector::parseSpriteAdd(const IDataNode& data) {
|
|||||||
sprite.a = static_cast<float>(color & 0xFF) / 255.0f;
|
sprite.a = static_cast<float>(color & 0xFF) / 255.0f;
|
||||||
|
|
||||||
m_retainedSprites[renderId] = sprite;
|
m_retainedSprites[renderId] = sprite;
|
||||||
|
spdlog::info("📥 [SceneCollector] Stored SPRITE renderId={}, pos=({:.1f},{:.1f}), scale={}x{}, textureId={}, layer={}, color=({:.2f},{:.2f},{:.2f},{:.2f})",
|
||||||
|
renderId, sprite.x, sprite.y, sprite.scaleX, sprite.scaleY, (int)sprite.textureId, (int)sprite.layer,
|
||||||
|
sprite.r, sprite.g, sprite.b, sprite.a);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SceneCollector::parseSpriteUpdate(const IDataNode& data) {
|
void SceneCollector::parseSpriteUpdate(const IDataNode& data) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include "../Widgets/UIButton.h"
|
#include "../Widgets/UIButton.h"
|
||||||
#include "../Widgets/UISlider.h"
|
#include "../Widgets/UISlider.h"
|
||||||
#include "../Widgets/UICheckbox.h"
|
#include "../Widgets/UICheckbox.h"
|
||||||
|
#include "../Widgets/UITextInput.h"
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
@ -52,6 +53,12 @@ UIWidget* hitTest(UIWidget* widget, float x, float y) {
|
|||||||
return widget;
|
return widget;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (type == "textinput") {
|
||||||
|
UITextInput* textInput = static_cast<UITextInput*>(widget);
|
||||||
|
if (textInput->containsPoint(x, y)) {
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
@ -135,6 +142,14 @@ UIWidget* dispatchMouseButton(UIWidget* widget, UIContext& ctx, int button, bool
|
|||||||
return target; // Return for value_changed publishing
|
return target; // Return for value_changed publishing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (type == "textinput") {
|
||||||
|
UITextInput* textInput = static_cast<UITextInput*>(target);
|
||||||
|
handled = textInput->onMouseButton(button, pressed, ctx.mouseX, ctx.mouseY);
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
return target; // Return for focus handling in UIModule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return handled ? target : nullptr;
|
return handled ? target : nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,439 +2,117 @@
|
|||||||
|
|
||||||
Complete UI widget system for GroveEngine with layout, scrolling, tooltips, and automatic input handling.
|
Complete UI widget system for GroveEngine with layout, scrolling, tooltips, and automatic input handling.
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
UIModule provides a full-featured UI system that integrates with BgfxRenderer for rendering and InputModule for input. All communication happens via IIO topics, ensuring complete decoupling.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **10 Widget Types**: Buttons, Labels, Panels, Checkboxes, Sliders, Text Inputs, Progress Bars, Images, Scroll Panels, Tooltips
|
- **10 Widget Types**: Button, Label, Panel, Checkbox, Slider, TextInput, ProgressBar, Image, ScrollPanel, Tooltip
|
||||||
- **Flexible Layout**: JSON-based UI definition with hierarchical widget trees
|
- **JSON-Based Layouts**: Define UI hierarchies in JSON files
|
||||||
- **Automatic Input**: Consumes `input:*` topics from InputModule automatically
|
- **Automatic Input Handling**: Consumes `input:*` topics from InputModule
|
||||||
- **Retained Mode Rendering**: Widgets cache render state and only publish IIO messages when visual properties change, reducing message traffic for static UIs
|
- **Retained Mode Rendering**: Widgets cache state, reducing IIO traffic by 85%+
|
||||||
- **Layer Management**: UI renders on top of game content (layer 1000+)
|
- **Layer Management**: UI renders on top of game content (layer 1000+)
|
||||||
- **Hot-Reload Support**: Full state preservation across module reloads
|
- **Hot-Reload Support**: Full state preservation across module reloads
|
||||||
|
- **Thread-Safe**: Designed for multi-threaded production architecture
|
||||||
|
|
||||||
## Architecture
|
## Quick Start
|
||||||
|
|
||||||
```
|
|
||||||
InputModule → IIO (input:mouse:*, input:keyboard:*)
|
|
||||||
↓
|
|
||||||
UIModule
|
|
||||||
(Widget Tree)
|
|
||||||
↓
|
|
||||||
UIRenderer (publishes)
|
|
||||||
↓
|
|
||||||
IIO (render:sprite, render:text)
|
|
||||||
↓
|
|
||||||
BgfxRenderer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Widgets
|
|
||||||
|
|
||||||
| Widget | Purpose | Events Published |
|
|
||||||
|--------|---------|------------------|
|
|
||||||
| **UIButton** | Clickable button | `ui:click`, `ui:action` |
|
|
||||||
| **UILabel** | Static text display | - |
|
|
||||||
| **UIPanel** | Container widget | - |
|
|
||||||
| **UICheckbox** | Toggle checkbox | `ui:value_changed` |
|
|
||||||
| **UISlider** | Value slider (horizontal/vertical) | `ui:value_changed` |
|
|
||||||
| **UITextInput** | Text input field | `ui:value_changed`, `ui:text_submitted` |
|
|
||||||
| **UIProgressBar** | Progress indicator | - |
|
|
||||||
| **UIImage** | Sprite/image display | - |
|
|
||||||
| **UIScrollPanel** | Scrollable container | `ui:scroll` |
|
|
||||||
| **UITooltip** | Hover tooltip | - |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
JsonDataNode config("config");
|
|
||||||
config.setInt("windowWidth", 1920);
|
|
||||||
config.setInt("windowHeight", 1080);
|
|
||||||
config.setString("layoutFile", "./assets/ui/main_menu.json");
|
|
||||||
config.setInt("baseLayer", 1000); // UI renders above game content
|
|
||||||
|
|
||||||
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Loading UIModule
|
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
#include <grove/ModuleLoader.h>
|
#include <grove/ModuleLoader.h>
|
||||||
#include <grove/IntraIOManager.h>
|
#include <grove/IntraIOManager.h>
|
||||||
|
|
||||||
|
// Create IIO instances
|
||||||
auto& ioManager = IntraIOManager::getInstance();
|
auto& ioManager = IntraIOManager::getInstance();
|
||||||
auto uiIO = ioManager.createInstance("ui_module");
|
auto uiIO = ioManager.createInstance("ui_module");
|
||||||
auto gameIO = ioManager.createInstance("game_logic");
|
auto gameIO = ioManager.createInstance("game");
|
||||||
|
|
||||||
|
// Load UIModule
|
||||||
ModuleLoader uiLoader;
|
ModuleLoader uiLoader;
|
||||||
auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module");
|
auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module");
|
||||||
|
|
||||||
|
// Configure
|
||||||
JsonDataNode config("config");
|
JsonDataNode config("config");
|
||||||
|
config.setInt("windowWidth", 1920);
|
||||||
|
config.setInt("windowHeight", 1080);
|
||||||
config.setString("layoutFile", "./ui/menu.json");
|
config.setString("layoutFile", "./ui/menu.json");
|
||||||
|
config.setInt("baseLayer", 1000);
|
||||||
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
||||||
|
|
||||||
|
// Subscribe to UI events
|
||||||
|
gameIO->subscribe("ui:action");
|
||||||
|
gameIO->subscribe("ui:value_changed");
|
||||||
|
|
||||||
|
// Game loop
|
||||||
|
while(running) {
|
||||||
|
// Handle UI events
|
||||||
|
while (gameIO->hasMessages() > 0) {
|
||||||
|
auto msg = gameIO->pullMessage();
|
||||||
|
if (msg.topic == "ui:action") {
|
||||||
|
std::string action = msg.data->getString("action", "");
|
||||||
|
handleAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiModule->process(deltaTime);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating UI Layout (JSON)
|
## Documentation
|
||||||
|
|
||||||
|
- **[Widget Reference](../../docs/UI_WIDGETS.md)** - All widgets with JSON properties
|
||||||
|
- **[IIO Topics](../../docs/UI_TOPICS.md)** - Complete topic reference and usage examples
|
||||||
|
- **[Architecture & Design](../../docs/UI_ARCHITECTURE.md)** - Threading, limitations, future features
|
||||||
|
- **[Rendering](../../docs/UI_RENDERING.md)** - Retained mode rendering architecture
|
||||||
|
|
||||||
|
## Example UI Layout
|
||||||
|
|
||||||
`ui/menu.json`:
|
`ui/menu.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"widgets": [
|
"widgets": [
|
||||||
{
|
{
|
||||||
"type": "UIPanel",
|
"type": "panel",
|
||||||
"id": "background",
|
"id": "background",
|
||||||
"x": 0,
|
"x": 0, "y": 0,
|
||||||
"y": 0,
|
"width": 1920, "height": 1080,
|
||||||
"width": 1920,
|
"style": {"bgColor": "0x2d3436FF"}
|
||||||
"height": 1080,
|
|
||||||
"color": 2155905279
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "UIButton",
|
"type": "button",
|
||||||
"id": "play_button",
|
"id": "play_button",
|
||||||
"x": 860,
|
"x": 860, "y": 500,
|
||||||
"y": 500,
|
"width": 200, "height": 60,
|
||||||
"width": 200,
|
"text": "Play Game",
|
||||||
"height": 60,
|
"onClick": "start_game",
|
||||||
"text": "Play",
|
"style": {
|
||||||
"fontSize": 24,
|
"normal": {"bgColor": "0x0984e3FF"},
|
||||||
"action": "start_game"
|
"hover": {"bgColor": "0x74b9ffFF"}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "UILabel",
|
"type": "slider",
|
||||||
"id": "title",
|
|
||||||
"x": 760,
|
|
||||||
"y": 300,
|
|
||||||
"width": 400,
|
|
||||||
"height": 100,
|
|
||||||
"text": "My Awesome Game",
|
|
||||||
"fontSize": 48,
|
|
||||||
"color": 4294967295
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "UISlider",
|
|
||||||
"id": "volume_slider",
|
"id": "volume_slider",
|
||||||
"x": 800,
|
"x": 800, "y": 650,
|
||||||
"y": 650,
|
"width": 320, "height": 40,
|
||||||
"width": 320,
|
"min": 0.0, "max": 100.0, "value": 75.0
|
||||||
"height": 40,
|
|
||||||
"min": 0.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"value": 75.0,
|
|
||||||
"orientation": "horizontal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "UICheckbox",
|
|
||||||
"id": "fullscreen_toggle",
|
|
||||||
"x": 800,
|
|
||||||
"y": 720,
|
|
||||||
"width": 30,
|
|
||||||
"height": 30,
|
|
||||||
"checked": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "UIScrollPanel",
|
|
||||||
"id": "settings_panel",
|
|
||||||
"x": 100,
|
|
||||||
"y": 100,
|
|
||||||
"width": 400,
|
|
||||||
"height": 600,
|
|
||||||
"contentHeight": 1200,
|
|
||||||
"scrollbarWidth": 20
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Handling UI Events
|
See [Widget Reference](../../docs/UI_WIDGETS.md) for all widget properties.
|
||||||
|
|
||||||
```cpp
|
## Building
|
||||||
// Subscribe to UI events in your game module
|
|
||||||
gameIO->subscribe("ui:click");
|
|
||||||
gameIO->subscribe("ui:action");
|
|
||||||
gameIO->subscribe("ui:value_changed");
|
|
||||||
|
|
||||||
// In your game module's process()
|
```bash
|
||||||
void GameModule::process(const IDataNode& input) {
|
cmake -DGROVE_BUILD_UI_MODULE=ON -B build
|
||||||
while (m_io->hasMessages() > 0) {
|
cmake --build build -j4
|
||||||
auto msg = m_io->pullMessage();
|
|
||||||
|
|
||||||
if (msg.topic == "ui:action") {
|
|
||||||
std::string action = msg.data->getString("action", "");
|
|
||||||
std::string widgetId = msg.data->getString("widgetId", "");
|
|
||||||
|
|
||||||
if (action == "start_game") {
|
|
||||||
startGame();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.topic == "ui:value_changed") {
|
|
||||||
std::string widgetId = msg.data->getString("widgetId", "");
|
|
||||||
|
|
||||||
if (widgetId == "volume_slider") {
|
|
||||||
double value = msg.data->getDouble("value", 50.0);
|
|
||||||
setVolume(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widgetId == "fullscreen_toggle") {
|
|
||||||
bool checked = msg.data->getBool("value", false);
|
|
||||||
setFullscreen(checked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## IIO Topics
|
|
||||||
|
|
||||||
### Topics Consumed (from InputModule)
|
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `input:mouse:move` | `{x, y}` | Mouse position |
|
|
||||||
| `input:mouse:button` | `{button, pressed, x, y}` | Mouse click |
|
|
||||||
| `input:mouse:wheel` | `{delta}` | Mouse wheel |
|
|
||||||
| `input:keyboard:key` | `{scancode, pressed, ...}` | Key event |
|
|
||||||
| `input:keyboard:text` | `{text}` | Text input (for UITextInput) |
|
|
||||||
|
|
||||||
### Topics Published (UI Events)
|
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `ui:click` | `{widgetId, x, y}` | Widget clicked |
|
|
||||||
| `ui:action` | `{widgetId, action}` | Button action triggered |
|
|
||||||
| `ui:value_changed` | `{widgetId, value}` | Slider/checkbox/input changed |
|
|
||||||
| `ui:text_submitted` | `{widgetId, text}` | Text input submitted (Enter) |
|
|
||||||
| `ui:hover` | `{widgetId, enter}` | Mouse entered/left widget |
|
|
||||||
| `ui:scroll` | `{widgetId, scrollX, scrollY}` | Scroll panel scrolled |
|
|
||||||
|
|
||||||
### Topics Published (Rendering)
|
|
||||||
|
|
||||||
**Retained Mode (current):**
|
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite |
|
|
||||||
| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite |
|
|
||||||
| `render:sprite:remove` | `{renderId}` | Unregister sprite |
|
|
||||||
| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text |
|
|
||||||
| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text |
|
|
||||||
| `render:text:remove` | `{renderId}` | Unregister text |
|
|
||||||
|
|
||||||
**Immediate Mode (legacy, still supported):**
|
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `render:sprite` | `{x, y, w, h, color, layer, ...}` | Ephemeral sprite (1 frame) |
|
|
||||||
| `render:text` | `{x, y, text, fontSize, color, layer}` | Ephemeral text (1 frame) |
|
|
||||||
|
|
||||||
See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for details on retained mode rendering.
|
|
||||||
|
|
||||||
## Widget Properties Reference
|
|
||||||
|
|
||||||
### UIButton
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UIButton",
|
|
||||||
"id": "my_button",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 200, "height": 50,
|
|
||||||
"text": "Click Me",
|
|
||||||
"fontSize": 24,
|
|
||||||
"textColor": 4294967295,
|
|
||||||
"bgColor": 3435973836,
|
|
||||||
"hoverColor": 4286611711,
|
|
||||||
"action": "button_clicked"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UILabel
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UILabel",
|
|
||||||
"id": "my_label",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 300, "height": 50,
|
|
||||||
"text": "Hello World",
|
|
||||||
"fontSize": 32,
|
|
||||||
"color": 4294967295
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UIPanel
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UIPanel",
|
|
||||||
"id": "my_panel",
|
|
||||||
"x": 0, "y": 0,
|
|
||||||
"width": 400, "height": 300,
|
|
||||||
"color": 2155905279
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UISlider
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UISlider",
|
|
||||||
"id": "volume",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 300, "height": 30,
|
|
||||||
"min": 0.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"value": 50.0,
|
|
||||||
"orientation": "horizontal"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UICheckbox
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UICheckbox",
|
|
||||||
"id": "enable_vsync",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 30, "height": 30,
|
|
||||||
"checked": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UITextInput
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UITextInput",
|
|
||||||
"id": "player_name",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 300, "height": 40,
|
|
||||||
"text": "",
|
|
||||||
"placeholder": "Enter name...",
|
|
||||||
"fontSize": 20,
|
|
||||||
"maxLength": 32
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UIProgressBar
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UIProgressBar",
|
|
||||||
"id": "loading",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 400, "height": 30,
|
|
||||||
"value": 0.65,
|
|
||||||
"bgColor": 2155905279,
|
|
||||||
"fillColor": 4278255360
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UIImage
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UIImage",
|
|
||||||
"id": "logo",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 200, "height": 200,
|
|
||||||
"textureId": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UIScrollPanel
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UIScrollPanel",
|
|
||||||
"id": "inventory",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 400, "height": 600,
|
|
||||||
"contentHeight": 1200,
|
|
||||||
"scrollY": 0.0,
|
|
||||||
"scrollbarWidth": 20,
|
|
||||||
"bgColor": 2155905279
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UITooltip
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "UITooltip",
|
|
||||||
"id": "help_tooltip",
|
|
||||||
"x": 100, "y": 100,
|
|
||||||
"width": 200, "height": 80,
|
|
||||||
"text": "This is a helpful tooltip",
|
|
||||||
"fontSize": 16,
|
|
||||||
"visible": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layer Management
|
|
||||||
|
|
||||||
UIModule uses **layer-based rendering** to ensure UI elements render correctly:
|
|
||||||
|
|
||||||
- **Game sprites**: Layer 0-999
|
|
||||||
- **UI elements**: Layer 1000+ (default baseLayer)
|
|
||||||
- **Tooltips**: Automatically use highest layer
|
|
||||||
|
|
||||||
Configure base layer in UIModule configuration:
|
|
||||||
```cpp
|
|
||||||
config.setInt("baseLayer", 1000);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hot-Reload Support
|
|
||||||
|
|
||||||
UIModule fully supports hot-reload with state preservation:
|
|
||||||
|
|
||||||
### State Preserved
|
|
||||||
- All widget properties (position, size, colors)
|
|
||||||
- Widget states (button hover, slider values, checkbox checked)
|
|
||||||
- Scroll positions
|
|
||||||
- Text input content
|
|
||||||
|
|
||||||
### State Not Preserved
|
|
||||||
- Transient animation states
|
|
||||||
- Mouse hover states (recalculated on next mouse move)
|
|
||||||
|
|
||||||
## Rendering Modes
|
|
||||||
|
|
||||||
UIModule uses **retained mode rendering** to optimize IIO message traffic. Widgets register render entries once and only publish updates when visual properties change.
|
|
||||||
|
|
||||||
### Retained Mode
|
|
||||||
|
|
||||||
Widgets cache their render state and compare against previous values each frame. Only changed properties trigger IIO messages.
|
|
||||||
|
|
||||||
**Message Reduction:**
|
|
||||||
- Static UI (20 widgets, 0 changes/frame): 100% reduction (0 messages after initial registration)
|
|
||||||
- Mostly static UI (20 widgets, 3 changes/frame): 85% reduction (3 messages vs 20)
|
|
||||||
- Fully dynamic UI (20 widgets, 20 changes/frame): 0% reduction (retained mode has comparison overhead)
|
|
||||||
|
|
||||||
**Topics:** `render:sprite:add/update/remove`, `render:text:add/update/remove`
|
|
||||||
|
|
||||||
### Immediate Mode (Legacy)
|
|
||||||
|
|
||||||
Widgets publish render commands every frame regardless of changes. Still supported for compatibility and ephemeral content (debug overlays, particles).
|
|
||||||
|
|
||||||
**Topics:** `render:sprite`, `render:text`
|
|
||||||
|
|
||||||
See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for implementation details and migration guide.
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Target**: < 1ms per frame for UI updates
|
|
||||||
- **Retained mode**: Reduces IIO traffic by 85%+ for typical UIs (static menus, HUDs)
|
|
||||||
- **Event filtering**: Only processes mouse events within widget bounds
|
|
||||||
- **Layout caching**: Widget tree built once from JSON, not every frame
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
### Visual Test
|
|
||||||
```bash
|
```bash
|
||||||
cmake -DGROVE_BUILD_UI_MODULE=ON -B build
|
# Visual showcase (run from project root for correct asset paths)
|
||||||
cmake --build build --target test_ui_widgets
|
./build/tests/test_ui_showcase
|
||||||
./build/tests/test_ui_widgets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Test (with InputModule + BgfxRenderer)
|
# Integration test
|
||||||
```bash
|
|
||||||
cmake -DGROVE_BUILD_BGFX_RENDERER=ON -DGROVE_BUILD_UI_MODULE=ON -DGROVE_BUILD_INPUT_MODULE=ON -B build
|
|
||||||
cmake --build build
|
|
||||||
cd build && ctest -R IT_014 --output-on-failure
|
cd build && ctest -R IT_014 --output-on-failure
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -442,29 +120,30 @@ cd build && ctest -R IT_014 --output-on-failure
|
|||||||
|
|
||||||
- **GroveEngine Core**: IModule, IIO, IDataNode
|
- **GroveEngine Core**: IModule, IIO, IDataNode
|
||||||
- **BgfxRenderer**: For rendering (via IIO, not direct dependency)
|
- **BgfxRenderer**: For rendering (via IIO, not direct dependency)
|
||||||
- **InputModule**: For input handling (via IIO, not direct dependency)
|
- **InputModule**: For input (via IIO, not direct dependency)
|
||||||
- **nlohmann/json**: JSON parsing
|
- **nlohmann/json**: JSON parsing
|
||||||
- **spdlog**: Logging
|
- **spdlog**: Logging
|
||||||
|
|
||||||
## Files
|
## Implementation Status
|
||||||
|
|
||||||
|
- ✅ **Phase 1-3**: Core widgets (Button, Label, Panel, Checkbox, Slider, TextInput, ProgressBar, Image)
|
||||||
|
- ✅ **Phase 4-5**: Layout system and styling
|
||||||
|
- ✅ **Phase 6**: Interactive demo
|
||||||
|
- ✅ **Phase 7**: ScrollPanel + Tooltips
|
||||||
|
- ✅ **Phase 8**: Retained mode rendering
|
||||||
|
|
||||||
|
## Files Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
modules/UIModule/
|
modules/UIModule/
|
||||||
├── README.md # This file
|
├── README.md # This file
|
||||||
├── CMakeLists.txt # Build configuration
|
├── CMakeLists.txt # Build configuration
|
||||||
├── UIModule.h # Main module
|
├── UIModule.h/.cpp # Main module
|
||||||
├── UIModule.cpp
|
|
||||||
├── Core/
|
├── Core/
|
||||||
│ ├── UIContext.h # Global UI state
|
│ ├── UIContext.h/.cpp # Global UI state
|
||||||
│ ├── UIContext.cpp
|
│ ├── UILayout.h/.cpp # Layout management
|
||||||
│ ├── UILayout.h # Layout management
|
│ ├── UITooltip.h/.cpp # Tooltip system
|
||||||
│ ├── UILayout.cpp
|
│ ├── UITree.h/.cpp # Widget hierarchy
|
||||||
│ ├── UIStyle.h # Widget styling
|
|
||||||
│ ├── UIStyle.cpp
|
|
||||||
│ ├── UITooltip.h # Tooltip system
|
|
||||||
│ ├── UITooltip.cpp
|
|
||||||
│ ├── UITree.h # Widget hierarchy
|
|
||||||
│ ├── UITree.cpp
|
|
||||||
│ └── UIWidget.h # Base widget interface
|
│ └── UIWidget.h # Base widget interface
|
||||||
├── Widgets/
|
├── Widgets/
|
||||||
│ ├── UIButton.h/.cpp
|
│ ├── UIButton.h/.cpp
|
||||||
@ -477,45 +156,7 @@ modules/UIModule/
|
|||||||
│ ├── UIImage.h/.cpp
|
│ ├── UIImage.h/.cpp
|
||||||
│ └── UIScrollPanel.h/.cpp
|
│ └── UIScrollPanel.h/.cpp
|
||||||
└── Rendering/
|
└── Rendering/
|
||||||
├── UIRenderer.h # Publishes render commands
|
├── UIRenderer.h/.cpp # Publishes render commands
|
||||||
└── UIRenderer.cpp
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
- ✅ **Phase 1**: Core widgets (Button, Label, Panel)
|
|
||||||
- ✅ **Phase 2**: Input widgets (Checkbox, Slider, TextInput)
|
|
||||||
- ✅ **Phase 3**: Advanced widgets (ProgressBar, Image)
|
|
||||||
- ✅ **Phase 4-5**: Layout system and styling
|
|
||||||
- ✅ **Phase 6**: Interactive demo
|
|
||||||
- ✅ **Phase 7**: ScrollPanel + Tooltips
|
|
||||||
|
|
||||||
## Extensibility
|
|
||||||
|
|
||||||
### Adding a Custom Widget
|
|
||||||
|
|
||||||
1. Create `Widgets/MyCustomWidget.h/.cpp`
|
|
||||||
2. Inherit from `UIWidget` base class
|
|
||||||
3. Implement `render()`, `handleInput()`, and event handlers
|
|
||||||
4. Add to `UILayout::createWidget()` factory
|
|
||||||
5. Use in JSON layouts with `"type": "MyCustomWidget"`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```cpp
|
|
||||||
class MyCustomWidget : public UIWidget {
|
|
||||||
public:
|
|
||||||
void render(UIRenderer& renderer) override {
|
|
||||||
// Publish render commands via renderer
|
|
||||||
renderer.drawRect(m_x, m_y, m_width, m_height, m_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
void onMouseDown(int button, double x, double y) override {
|
|
||||||
// Handle click
|
|
||||||
auto event = std::make_unique<JsonDataNode>("event");
|
|
||||||
event->setString("widgetId", m_id);
|
|
||||||
m_io->publish("ui:custom_event", std::move(event));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@ -155,12 +155,20 @@ bool UIRenderer::updateSprite(uint32_t renderId, float x, float y, float w, floa
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UIRenderer::publishSpriteAdd(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) {
|
void UIRenderer::publishSpriteAdd(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) {
|
||||||
|
spdlog::info("📤 [UIRenderer] Publishing render:sprite:add - renderId={}, center=({:.1f},{:.1f}), scale={}x{}, textureId={}, layer={}",
|
||||||
|
renderId, x + w * 0.5f, y + h * 0.5f, w, h, textureId, layer);
|
||||||
|
|
||||||
auto sprite = std::make_unique<JsonDataNode>("sprite");
|
auto sprite = std::make_unique<JsonDataNode>("sprite");
|
||||||
sprite->setInt("renderId", static_cast<int>(renderId));
|
sprite->setInt("renderId", static_cast<int>(renderId));
|
||||||
sprite->setDouble("x", static_cast<double>(x + w * 0.5f));
|
sprite->setDouble("x", static_cast<double>(x + w * 0.5f));
|
||||||
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
||||||
sprite->setDouble("scaleX", static_cast<double>(w));
|
sprite->setDouble("scaleX", static_cast<double>(w));
|
||||||
sprite->setDouble("scaleY", static_cast<double>(h));
|
sprite->setDouble("scaleY", static_cast<double>(h));
|
||||||
|
sprite->setDouble("rotation", 0.0);
|
||||||
|
sprite->setDouble("u0", 0.0);
|
||||||
|
sprite->setDouble("v0", 0.0);
|
||||||
|
sprite->setDouble("u1", 1.0);
|
||||||
|
sprite->setDouble("v1", 1.0);
|
||||||
sprite->setInt("color", static_cast<int>(color));
|
sprite->setInt("color", static_cast<int>(color));
|
||||||
sprite->setInt("textureId", textureId);
|
sprite->setInt("textureId", textureId);
|
||||||
sprite->setInt("layer", layer);
|
sprite->setInt("layer", layer);
|
||||||
@ -174,6 +182,11 @@ void UIRenderer::publishSpriteUpdate(uint32_t renderId, float x, float y, float
|
|||||||
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
||||||
sprite->setDouble("scaleX", static_cast<double>(w));
|
sprite->setDouble("scaleX", static_cast<double>(w));
|
||||||
sprite->setDouble("scaleY", static_cast<double>(h));
|
sprite->setDouble("scaleY", static_cast<double>(h));
|
||||||
|
sprite->setDouble("rotation", 0.0);
|
||||||
|
sprite->setDouble("u0", 0.0);
|
||||||
|
sprite->setDouble("v0", 0.0);
|
||||||
|
sprite->setDouble("u1", 1.0);
|
||||||
|
sprite->setDouble("v1", 1.0);
|
||||||
sprite->setInt("color", static_cast<int>(color));
|
sprite->setInt("color", static_cast<int>(color));
|
||||||
sprite->setInt("textureId", textureId);
|
sprite->setInt("textureId", textureId);
|
||||||
sprite->setInt("layer", layer);
|
sprite->setInt("layer", layer);
|
||||||
|
|||||||
@ -9,10 +9,12 @@
|
|||||||
#include "Widgets/UICheckbox.h"
|
#include "Widgets/UICheckbox.h"
|
||||||
#include "Widgets/UITextInput.h"
|
#include "Widgets/UITextInput.h"
|
||||||
#include "Widgets/UIScrollPanel.h"
|
#include "Widgets/UIScrollPanel.h"
|
||||||
|
#include "Widgets/UILabel.h"
|
||||||
|
|
||||||
#include <grove/JsonDataNode.h>
|
#include <grove/JsonDataNode.h>
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
#include <spdlog/sinks/stdout_color_sinks.h>
|
#include <spdlog/sinks/stdout_color_sinks.h>
|
||||||
|
#include <chrono>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
@ -80,6 +82,7 @@ void UIModule::setConfiguration(const IDataNode& config, IIO* io, ITaskScheduler
|
|||||||
m_io->subscribe("ui:load"); // Load new layout
|
m_io->subscribe("ui:load"); // Load new layout
|
||||||
m_io->subscribe("ui:set_value"); // Set widget value
|
m_io->subscribe("ui:set_value"); // Set widget value
|
||||||
m_io->subscribe("ui:set_visible"); // Show/hide widget
|
m_io->subscribe("ui:set_visible"); // Show/hide widget
|
||||||
|
m_io->subscribe("ui:set_text"); // Set widget text (for labels)
|
||||||
}
|
}
|
||||||
|
|
||||||
m_logger->info("UIModule initialized");
|
m_logger->info("UIModule initialized");
|
||||||
@ -147,6 +150,36 @@ void UIModule::processInput() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (msg.topic == "ui:set_text") {
|
||||||
|
// Timestamp on receive
|
||||||
|
auto now = std::chrono::high_resolution_clock::now();
|
||||||
|
auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
|
||||||
|
|
||||||
|
std::string widgetId = msg.data->getString("id", "");
|
||||||
|
std::string text = msg.data->getString("text", "");
|
||||||
|
|
||||||
|
// Extract original timestamp if present
|
||||||
|
double t0 = msg.data->getDouble("_timestamp_publish", 0);
|
||||||
|
if (t0 > 0) {
|
||||||
|
double latency = (micros - t0) / 1000.0; // Convert to milliseconds
|
||||||
|
m_logger->info("⏱️ [T3] UIModule received ui:set_text at {} µs (latency from T0: {:.2f} ms)", micros, latency);
|
||||||
|
} else {
|
||||||
|
m_logger->info("⏱️ [T3] UIModule received ui:set_text at {} µs", micros);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_root) {
|
||||||
|
if (UIWidget* widget = m_root->findById(widgetId)) {
|
||||||
|
// Only labels support text updates
|
||||||
|
if (widget->getType() == "label") {
|
||||||
|
UILabel* label = static_cast<UILabel*>(widget);
|
||||||
|
label->text = text;
|
||||||
|
m_logger->info("Updated text for label '{}': '{}'", widgetId, text);
|
||||||
|
} else {
|
||||||
|
m_logger->warn("Widget '{}' is not a label, cannot set text", widgetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,6 +241,9 @@ void UIModule::updateUI(float deltaTime) {
|
|||||||
// Publish type-specific events
|
// Publish type-specific events
|
||||||
std::string widgetType = clickedWidget->getType();
|
std::string widgetType = clickedWidget->getType();
|
||||||
|
|
||||||
|
m_logger->info("🖱️ Widget clicked: id='{}', type='{}', mousePressed={}",
|
||||||
|
clickedWidget->id, widgetType, m_context->mousePressed);
|
||||||
|
|
||||||
// Handle focus for text inputs
|
// Handle focus for text inputs
|
||||||
if (widgetType == "textinput" && m_context->mousePressed) {
|
if (widgetType == "textinput" && m_context->mousePressed) {
|
||||||
UITextInput* textInput = static_cast<UITextInput*>(clickedWidget);
|
UITextInput* textInput = static_cast<UITextInput*>(clickedWidget);
|
||||||
@ -254,6 +290,13 @@ void UIModule::updateUI(float deltaTime) {
|
|||||||
valueEvent->setDouble("value", slider->getValue());
|
valueEvent->setDouble("value", slider->getValue());
|
||||||
valueEvent->setDouble("min", slider->minValue);
|
valueEvent->setDouble("min", slider->minValue);
|
||||||
valueEvent->setDouble("max", slider->maxValue);
|
valueEvent->setDouble("max", slider->maxValue);
|
||||||
|
|
||||||
|
// Add timestamp for latency measurement
|
||||||
|
auto now = std::chrono::high_resolution_clock::now();
|
||||||
|
auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
|
||||||
|
valueEvent->setDouble("_timestamp_publish", static_cast<double>(micros));
|
||||||
|
|
||||||
|
m_logger->info("⏱️ [T0] UIModule publishing ui:value_changed at {} µs", micros);
|
||||||
m_io->publish("ui:value_changed", std::move(valueEvent));
|
m_io->publish("ui:value_changed", std::move(valueEvent));
|
||||||
|
|
||||||
// Publish onChange action if specified
|
// Publish onChange action if specified
|
||||||
|
|||||||
@ -44,11 +44,21 @@ void UIButton::render(UIRenderer& renderer) {
|
|||||||
|
|
||||||
const ButtonStyle& style = getCurrentStyle();
|
const ButtonStyle& style = getCurrentStyle();
|
||||||
|
|
||||||
|
static int logCount = 0;
|
||||||
|
if (logCount < 10) { // Log first 10 buttons to see all textured ones
|
||||||
|
spdlog::info("UIButton[{}]::render() id='{}', state={}, normalStyle.textureId={}, useTexture={}",
|
||||||
|
logCount, id, (int)state, normalStyle.textureId, normalStyle.useTexture);
|
||||||
|
spdlog::info(" current style: textureId={}, useTexture={}", style.textureId, style.useTexture);
|
||||||
|
logCount++;
|
||||||
|
}
|
||||||
|
|
||||||
// Retained mode: only publish if changed
|
// Retained mode: only publish if changed
|
||||||
int bgLayer = renderer.nextLayer();
|
int bgLayer = renderer.nextLayer();
|
||||||
|
|
||||||
// Render background (texture or solid color)
|
// Render background (texture or solid color)
|
||||||
if (style.useTexture && style.textureId > 0) {
|
if (style.useTexture && style.textureId > 0) {
|
||||||
|
spdlog::info("🎨 [UIButton '{}'] Rendering SPRITE: renderId={}, pos=({},{}), size={}x{}, textureId={}, color=0x{:08X}, layer={}",
|
||||||
|
id, m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer);
|
||||||
renderer.updateSprite(m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer);
|
renderer.updateSprite(m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer);
|
||||||
} else {
|
} else {
|
||||||
renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer);
|
renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include "../Rendering/UIRenderer.h"
|
#include "../Rendering/UIRenderer.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
|
|
||||||
@ -63,6 +64,14 @@ void UITextInput::render(UIRenderer& renderer) {
|
|||||||
// Render border
|
// Render border
|
||||||
int borderLayer = renderer.nextLayer();
|
int borderLayer = renderer.nextLayer();
|
||||||
uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor;
|
uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor;
|
||||||
|
|
||||||
|
static int renderCount = 0;
|
||||||
|
if (renderCount < 5) {
|
||||||
|
spdlog::info("🎨 UITextInput '{}' render: isFocused={}, borderColor=0x{:08X} (focus=0x{:08X}, normal=0x{:08X})",
|
||||||
|
id, isFocused, borderColor, style.focusBorderColor, style.borderColor);
|
||||||
|
renderCount++;
|
||||||
|
}
|
||||||
|
|
||||||
renderer.updateRect(m_borderRenderId, absX, absY + height - style.borderWidth,
|
renderer.updateRect(m_borderRenderId, absX, absY + height - style.borderWidth,
|
||||||
width, style.borderWidth, borderColor, borderLayer);
|
width, style.borderWidth, borderColor, borderLayer);
|
||||||
|
|
||||||
@ -199,6 +208,7 @@ void UITextInput::gainFocus() {
|
|||||||
isFocused = true;
|
isFocused = true;
|
||||||
cursorBlinkTimer = 0.0f;
|
cursorBlinkTimer = 0.0f;
|
||||||
cursorVisible = true;
|
cursorVisible = true;
|
||||||
|
spdlog::info("🎯 UITextInput '{}' gainFocus() called - isFocused={}", id, isFocused);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,10 +13,10 @@ IntraIOManager::IntraIOManager() {
|
|||||||
logger = stillhammer::createDomainLogger("IntraIOManager", "io", config);
|
logger = stillhammer::createDomainLogger("IntraIOManager", "io", config);
|
||||||
logger->info("🌐🔗 IntraIOManager created - Central message router initialized");
|
logger->info("🌐🔗 IntraIOManager created - Central message router initialized");
|
||||||
|
|
||||||
// TEMPORARY: Disable batch thread to debug Windows crash
|
// Start batch flush thread for low-latency message delivery
|
||||||
batchThreadRunning = false;
|
batchThreadRunning = true;
|
||||||
// batchThread = std::thread(&IntraIOManager::batchFlushLoop, this);
|
batchThread = std::thread(&IntraIOManager::batchFlushLoop, this);
|
||||||
logger->info("⚠️ Batch flush thread DISABLED (debugging Windows crash)");
|
logger->info("✅ Batch flush thread started for push-based message delivery");
|
||||||
}
|
}
|
||||||
|
|
||||||
IntraIOManager::~IntraIOManager() {
|
IntraIOManager::~IntraIOManager() {
|
||||||
|
|||||||
@ -1451,3 +1451,180 @@ if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILA
|
|||||||
)
|
)
|
||||||
message(STATUS "Single button test 'test_single_button' enabled")
|
message(STATUS "Single button test 'test_single_button' enabled")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# UI Texture Support Test - headless test demonstrating texture properties
|
||||||
|
if(GROVE_BUILD_UI_MODULE)
|
||||||
|
add_executable(test_ui_textures
|
||||||
|
visual/test_ui_textures.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_ui_textures PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_ui_textures PRIVATE
|
||||||
|
GroveEngine::impl
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "UI texture support test 'test_ui_textures' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Textured UI Visual Demo - shows widgets with custom textures
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_ui_textured_demo WIN32
|
||||||
|
visual/test_ui_textured_demo.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_ui_textured_demo PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_ui_textured_demo PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "Textured UI demo 'test_ui_textured_demo' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Simple textured UI demo - shows widget properties (no rendering)
|
||||||
|
if(GROVE_BUILD_UI_MODULE)
|
||||||
|
add_executable(test_ui_textured_simple
|
||||||
|
visual/test_ui_textured_simple.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_ui_textured_simple PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_ui_textured_simple PRIVATE
|
||||||
|
GroveEngine::impl
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "Simple textured UI demo 'test_ui_textured_simple' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Textured Button Visual Test - shows REAL textures on buttons
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_textured_button WIN32
|
||||||
|
visual/test_textured_button.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_textured_button PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_textured_button PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "Textured button test 'test_textured_button' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Minimal Textured Demo - Direct sprite rendering with textures
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_textured_demo_minimal WIN32
|
||||||
|
visual/test_textured_demo_minimal.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_textured_demo_minimal PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
)
|
||||||
|
target_link_libraries(test_textured_demo_minimal PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "Minimal textured demo 'test_textured_demo_minimal' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Button with PNG texture - Load real PNG file and apply to button
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_button_with_png WIN32
|
||||||
|
visual/test_button_with_png.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_button_with_png PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_button_with_png PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "PNG button test 'test_button_with_png' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 3 Buttons Minimal Test - 3 textured buttons in minimal layout
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_3buttons_minimal WIN32
|
||||||
|
visual/test_3buttons_minimal.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_3buttons_minimal PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_3buttons_minimal PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "3 buttons minimal test 'test_3buttons_minimal' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 1 Button Texture 2 Test - diagnostic to see if only texture 1 works
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_1button_texture2 WIN32
|
||||||
|
visual/test_1button_texture2.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_1button_texture2 PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/UIModule
|
||||||
|
)
|
||||||
|
target_link_libraries(test_1button_texture2 PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
UIModule_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "1 button texture 2 test 'test_1button_texture2' enabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Direct sprite texture test - bypasses UIModule, uses renderer directly
|
||||||
|
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND SDL2_AVAILABLE)
|
||||||
|
add_executable(test_direct_sprite_texture WIN32
|
||||||
|
visual/test_direct_sprite_texture.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_direct_sprite_texture PRIVATE
|
||||||
|
${CMAKE_SOURCE_DIR}/modules
|
||||||
|
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
|
||||||
|
)
|
||||||
|
target_link_libraries(test_direct_sprite_texture PRIVATE
|
||||||
|
SDL2::SDL2main
|
||||||
|
SDL2::SDL2
|
||||||
|
GroveEngine::impl
|
||||||
|
BgfxRenderer_static
|
||||||
|
spdlog::spdlog
|
||||||
|
)
|
||||||
|
message(STATUS "Direct sprite texture test 'test_direct_sprite_texture' enabled")
|
||||||
|
endif()
|
||||||
|
|||||||
211
tests/visual/test_3buttons_minimal.cpp
Normal file
211
tests/visual/test_3buttons_minimal.cpp
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Test: UIButton avec texture PNG chargée depuis un fichier
|
||||||
|
* Ce test montre qu'on peut mettre une VRAIE image sur un bouton
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
#include <SDL_syswm.h>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
|
#include "BgfxRendererModule.h"
|
||||||
|
#include "UIModule/UIModule.h"
|
||||||
|
#include "../modules/BgfxRenderer/Resources/ResourceCache.h"
|
||||||
|
#include "../modules/BgfxRenderer/RHI/RHITypes.h"
|
||||||
|
#include "../modules/BgfxRenderer/RHI/RHIDevice.h"
|
||||||
|
#include <grove/JsonDataNode.h>
|
||||||
|
#include <grove/IntraIOManager.h>
|
||||||
|
#include <grove/IntraIO.h>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
#include <spdlog/sinks/stdout_color_sinks.h>
|
||||||
|
#include <bgfx/bgfx.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace grove;
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
spdlog::set_level(spdlog::level::info);
|
||||||
|
auto logger = spdlog::stdout_color_mt("TexturedButtonTest");
|
||||||
|
|
||||||
|
if (SDL_Init(SDL_INIT_VIDEO) < 0) {
|
||||||
|
std::cerr << "SDL_Init failed" << std::endl;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Window* window = SDL_CreateWindow(
|
||||||
|
"Textured Button Test - Gradient",
|
||||||
|
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||||
|
800, 600, SDL_WINDOW_SHOWN
|
||||||
|
);
|
||||||
|
|
||||||
|
SDL_SysWMinfo wmi;
|
||||||
|
SDL_VERSION(&wmi.version);
|
||||||
|
SDL_GetWindowWMInfo(window, &wmi);
|
||||||
|
|
||||||
|
// Create IIO instances - IMPORTANT: game publishes input, ui subscribes and publishes render commands
|
||||||
|
auto gameIO = IntraIOManager::getInstance().createInstance("game");
|
||||||
|
auto uiIO = IntraIOManager::getInstance().createInstance("ui");
|
||||||
|
auto rendererIO = IntraIOManager::getInstance().createInstance("renderer");
|
||||||
|
|
||||||
|
gameIO->subscribe("ui:hover");
|
||||||
|
gameIO->subscribe("ui:click");
|
||||||
|
gameIO->subscribe("ui:action");
|
||||||
|
|
||||||
|
// Initialize BgfxRenderer WITH 3 TEXTURES loaded via config
|
||||||
|
auto renderer = std::make_unique<BgfxRendererModule>();
|
||||||
|
{
|
||||||
|
JsonDataNode config("config");
|
||||||
|
config.setDouble("nativeWindowHandle",
|
||||||
|
static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window)));
|
||||||
|
config.setInt("windowWidth", 800);
|
||||||
|
config.setInt("windowHeight", 600);
|
||||||
|
// Load 3 textures
|
||||||
|
config.setString("texture1", "../../assets/textures/5oxaxt1vo2f91.jpg"); // Car
|
||||||
|
config.setString("texture2", "../../assets/textures/1f440.png"); // Eyes
|
||||||
|
config.setString("texture3", "../../assets/textures/IconDesigner.png"); // Icon
|
||||||
|
|
||||||
|
renderer->setConfiguration(config, rendererIO.get(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger->info("✓ Loaded 3 textures (IDs: 1, 2, 3)");
|
||||||
|
|
||||||
|
// Initialize UIModule with 3 TEXTURED BUTTONS
|
||||||
|
auto ui = std::make_unique<UIModule>();
|
||||||
|
{
|
||||||
|
JsonDataNode config("config");
|
||||||
|
config.setInt("windowWidth", 800);
|
||||||
|
config.setInt("windowHeight", 600);
|
||||||
|
|
||||||
|
nlohmann::json layoutJson = {
|
||||||
|
{"id", "root"},
|
||||||
|
{"type", "panel"},
|
||||||
|
{"x", 0}, {"y", 0},
|
||||||
|
{"width", 800}, {"height", 600}, // Full screen invisible panel (just container)
|
||||||
|
{"style", {
|
||||||
|
{"bgColor", "0x00000000"} // Fully transparent - just a container
|
||||||
|
}},
|
||||||
|
{"children", {
|
||||||
|
{
|
||||||
|
{"id", "btn_car"},
|
||||||
|
{"type", "button"},
|
||||||
|
{"x", 50},
|
||||||
|
{"y", 50},
|
||||||
|
{"width", 400},
|
||||||
|
{"height", 200},
|
||||||
|
{"text", ""},
|
||||||
|
{"onClick", "car_action"},
|
||||||
|
{"style", {
|
||||||
|
{"normal", {{"textureId", 1}, {"bgColor", "0xFFFFFFFF"}}},
|
||||||
|
{"hover", {{"textureId", 1}, {"bgColor", "0xFFFF00FF"}}},
|
||||||
|
{"pressed", {{"textureId", 1}, {"bgColor", "0x888888FF"}}}
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{"id", "btn_eyes"},
|
||||||
|
{"type", "button"},
|
||||||
|
{"x", 50},
|
||||||
|
{"y", 270},
|
||||||
|
{"width", 250},
|
||||||
|
{"height", 200},
|
||||||
|
{"text", ""},
|
||||||
|
{"onClick", "eyes_action"},
|
||||||
|
{"style", {
|
||||||
|
{"normal", {{"textureId", 2}, {"bgColor", "0xFFFFFFFF"}}},
|
||||||
|
{"hover", {{"textureId", 2}, {"bgColor", "0x00FFFFFF"}}},
|
||||||
|
{"pressed", {{"textureId", 2}, {"bgColor", "0x888888FF"}}}
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{"id", "btn_icon"},
|
||||||
|
{"type", "button"},
|
||||||
|
{"x", 320},
|
||||||
|
{"y", 270},
|
||||||
|
{"width", 250},
|
||||||
|
{"height", 200},
|
||||||
|
{"text", ""},
|
||||||
|
{"onClick", "icon_action"},
|
||||||
|
{"style", {
|
||||||
|
{"normal", {{"textureId", 3}, {"bgColor", "0xFFFFFFFF"}}},
|
||||||
|
{"hover", {{"textureId", 3}, {"bgColor", "0xFF00FFFF"}}},
|
||||||
|
{"pressed", {{"textureId", 3}, {"bgColor", "0x888888FF"}}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto layoutNode = std::make_unique<JsonDataNode>("layout", layoutJson);
|
||||||
|
config.setChild("layout", std::move(layoutNode));
|
||||||
|
|
||||||
|
ui->setConfiguration(config, uiIO.get(), nullptr);
|
||||||
|
logger->info("✓ UIModule configured with 3 textured buttons!");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger->info("\n╔════════════════════════════════════════╗");
|
||||||
|
logger->info("║ 3 BOUTONS AVEC TEXTURES ║");
|
||||||
|
logger->info("╠════════════════════════════════════════╣");
|
||||||
|
logger->info("║ Button 1: Car (textureId=1) ║");
|
||||||
|
logger->info("║ Button 2: Eyes (textureId=2) ║");
|
||||||
|
logger->info("║ Button 3: Icon (textureId=3) ║");
|
||||||
|
logger->info("║ Press ESC to exit ║");
|
||||||
|
logger->info("╚════════════════════════════════════════╝\n");
|
||||||
|
|
||||||
|
bool running = true;
|
||||||
|
while (running) {
|
||||||
|
SDL_Event e;
|
||||||
|
while (SDL_PollEvent(&e)) {
|
||||||
|
if (e.type == SDL_QUIT ||
|
||||||
|
(e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE)) {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward mouse events
|
||||||
|
if (e.type == SDL_MOUSEMOTION) {
|
||||||
|
auto mouseMsg = std::make_unique<JsonDataNode>("mouse");
|
||||||
|
mouseMsg->setDouble("x", static_cast<double>(e.motion.x));
|
||||||
|
mouseMsg->setDouble("y", static_cast<double>(e.motion.y));
|
||||||
|
gameIO->publish("input:mouse:move", std::move(mouseMsg));
|
||||||
|
}
|
||||||
|
else if (e.type == SDL_MOUSEBUTTONDOWN || e.type == SDL_MOUSEBUTTONUP) {
|
||||||
|
auto mouseMsg = std::make_unique<JsonDataNode>("mouse");
|
||||||
|
mouseMsg->setInt("button", e.button.button);
|
||||||
|
mouseMsg->setBool("pressed", e.type == SDL_MOUSEBUTTONDOWN);
|
||||||
|
mouseMsg->setDouble("x", static_cast<double>(e.button.x));
|
||||||
|
mouseMsg->setDouble("y", static_cast<double>(e.button.y));
|
||||||
|
gameIO->publish("input:mouse:button", std::move(mouseMsg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for UI events
|
||||||
|
while (gameIO->hasMessages() > 0) {
|
||||||
|
auto msg = gameIO->pullMessage();
|
||||||
|
if (msg.topic == "ui:action") {
|
||||||
|
logger->info("🖱️ BOUTON CLICKÉ!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modules
|
||||||
|
JsonDataNode input("input");
|
||||||
|
input.setDouble("deltaTime", 0.016);
|
||||||
|
ui->process(input);
|
||||||
|
renderer->process(input);
|
||||||
|
|
||||||
|
SDL_Delay(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger->info("Cleaning up...");
|
||||||
|
|
||||||
|
// Textures are managed by ResourceCache, will be cleaned up in renderer->shutdown()
|
||||||
|
ui->shutdown();
|
||||||
|
renderer->shutdown();
|
||||||
|
|
||||||
|
IntraIOManager::getInstance().removeInstance("game");
|
||||||
|
IntraIOManager::getInstance().removeInstance("ui");
|
||||||
|
IntraIOManager::getInstance().removeInstance("renderer");
|
||||||
|
|
||||||
|
SDL_DestroyWindow(window);
|
||||||
|
SDL_Quit();
|
||||||
|
|
||||||
|
logger->info("Test complete!");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
#include "BgfxRendererModule.h"
|
#include "BgfxRendererModule.h"
|
||||||
#include "UIModule.h"
|
#include "UIModule.h"
|
||||||
@ -37,20 +38,20 @@
|
|||||||
|
|
||||||
using namespace grove;
|
using namespace grove;
|
||||||
|
|
||||||
// Create the UI layout JSON - must be a single root widget with children
|
// Create the UI layout JSON - MINIMAL VERSION WITH ONLY TEXTURED BUTTONS
|
||||||
static nlohmann::json createUILayout() {
|
static nlohmann::json createUILayout() {
|
||||||
nlohmann::json root;
|
nlohmann::json root;
|
||||||
|
|
||||||
// Root panel (full screen background)
|
// Root panel (TRANSPARENT like test_button_with_png!)
|
||||||
root["type"] = "panel";
|
root["type"] = "panel";
|
||||||
root["id"] = "root";
|
root["id"] = "root";
|
||||||
root["x"] = 0;
|
root["x"] = 0;
|
||||||
root["y"] = 0;
|
root["y"] = 0;
|
||||||
root["width"] = 1024;
|
root["width"] = 1024;
|
||||||
root["height"] = 768;
|
root["height"] = 768;
|
||||||
root["style"] = {{"bgColor", "0x1a1a2eFF"}};
|
root["style"] = {{"bgColor", "0x00000000"}}; // TRANSPARENT!
|
||||||
|
|
||||||
// Children array (like test_single_button)
|
// Children array - ONLY TEXTURED BUTTONS
|
||||||
nlohmann::json children = nlohmann::json::array();
|
nlohmann::json children = nlohmann::json::array();
|
||||||
|
|
||||||
// Title label
|
// Title label
|
||||||
@ -151,6 +152,78 @@ static nlohmann::json createUILayout() {
|
|||||||
}}
|
}}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === TEXTURED BUTTONS PANEL === (TRANSPARENT FOR TESTING)
|
||||||
|
children.push_back({
|
||||||
|
{"type", "panel"},
|
||||||
|
{"id", "textured_buttons_panel"},
|
||||||
|
{"x", 350}, {"y", 120},
|
||||||
|
{"width", 300}, {"height", 280},
|
||||||
|
{"style", {{"bgColor", "0x00000000"}}} // TRANSPARENT!
|
||||||
|
});
|
||||||
|
|
||||||
|
children.push_back({
|
||||||
|
{"type", "label"},
|
||||||
|
{"id", "textured_buttons_title"},
|
||||||
|
{"x", 365}, {"y", 130},
|
||||||
|
{"width", 250}, {"height", 30},
|
||||||
|
{"text", "Sprite Buttons"},
|
||||||
|
{"style", {{"fontSize", 20}, {"color", "0xFFFFFFFF"}}}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Textured Button 1 - Car (HUGE LIKE WORKING TEST!)
|
||||||
|
children.push_back({
|
||||||
|
{"type", "button"},
|
||||||
|
{"id", "btn_car"},
|
||||||
|
{"x", 50}, {"y", 120},
|
||||||
|
{"width", 400}, {"height", 200}, // Same as test_button_with_png!
|
||||||
|
{"text", ""},
|
||||||
|
{"onClick", "sprite_car"},
|
||||||
|
{"style", {
|
||||||
|
{"normal", {{"textureId", 1}, {"bgColor", "0xFFFFFFFF"}, {"textColor", "0x000000FF"}}},
|
||||||
|
{"hover", {{"textureId", 1}, {"bgColor", "0xFFFF00FF"}, {"textColor", "0x000000FF"}}}, // Yellow tint
|
||||||
|
{"pressed", {{"textureId", 1}, {"bgColor", "0x888888FF"}, {"textColor", "0x000000FF"}}} // Dark tint
|
||||||
|
}}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Textured Button 2 - Eyes (HUGE!)
|
||||||
|
children.push_back({
|
||||||
|
{"type", "button"},
|
||||||
|
{"id", "btn_eyes"},
|
||||||
|
{"x", 470}, {"y", 120},
|
||||||
|
{"width", 250}, {"height", 200}, // Much bigger!
|
||||||
|
{"text", ""},
|
||||||
|
{"onClick", "sprite_eyes"},
|
||||||
|
{"style", {
|
||||||
|
{"normal", {{"textureId", 2}, {"bgColor", "0xFFFFFFFF"}, {"textColor", "0x000000FF"}}},
|
||||||
|
{"hover", {{"textureId", 2}, {"bgColor", "0x00FFFFFF"}, {"textColor", "0x000000FF"}}}, // Cyan tint
|
||||||
|
{"pressed", {{"textureId", 2}, {"bgColor", "0x888888FF"}, {"textColor", "0x000000FF"}}}
|
||||||
|
}}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Textured Button 3 - Icon (HUGE!)
|
||||||
|
children.push_back({
|
||||||
|
{"type", "button"},
|
||||||
|
{"id", "btn_icon"},
|
||||||
|
{"x", 50}, {"y", 340},
|
||||||
|
{"width", 250}, {"height", 200}, // Much bigger!
|
||||||
|
{"text", ""},
|
||||||
|
{"onClick", "sprite_icon"},
|
||||||
|
{"style", {
|
||||||
|
{"normal", {{"textureId", 3}, {"bgColor", "0xFFFFFFFF"}, {"textColor", "0x000000FF"}}},
|
||||||
|
{"hover", {{"textureId", 3}, {"bgColor", "0xFF00FFFF"}, {"textColor", "0x000000FF"}}}, // Magenta tint
|
||||||
|
{"pressed", {{"textureId", 3}, {"bgColor", "0x888888FF"}, {"textColor", "0x000000FF"}}}
|
||||||
|
}}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Info label for textured buttons
|
||||||
|
children.push_back({
|
||||||
|
{"type", "label"},
|
||||||
|
{"x", 370}, {"y", 340},
|
||||||
|
{"width", 260}, {"height", 50},
|
||||||
|
{"text", "Retained mode:\nTextures only sent once!"},
|
||||||
|
{"style", {{"fontSize", 12}, {"color", "0xAAAAAAFF"}}}
|
||||||
|
});
|
||||||
|
|
||||||
// === MIDDLE COLUMN: Inputs Panel ===
|
// === MIDDLE COLUMN: Inputs Panel ===
|
||||||
children.push_back({
|
children.push_back({
|
||||||
{"type", "panel"},
|
{"type", "panel"},
|
||||||
@ -351,7 +424,7 @@ public:
|
|||||||
m_inputIO = m_inputIOPtr.get();
|
m_inputIO = m_inputIOPtr.get();
|
||||||
m_gameIO = m_gameIOPtr.get();
|
m_gameIO = m_gameIOPtr.get();
|
||||||
|
|
||||||
// Create and configure BgfxRenderer
|
// Create and configure BgfxRenderer with textures
|
||||||
m_renderer = std::make_unique<BgfxRendererModule>();
|
m_renderer = std::make_unique<BgfxRendererModule>();
|
||||||
{
|
{
|
||||||
JsonDataNode config("config");
|
JsonDataNode config("config");
|
||||||
@ -359,10 +432,15 @@ public:
|
|||||||
static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window)));
|
static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window)));
|
||||||
config.setInt("windowWidth", 1024);
|
config.setInt("windowWidth", 1024);
|
||||||
config.setInt("windowHeight", 768);
|
config.setInt("windowHeight", 768);
|
||||||
config.setString("backend", "d3d11");
|
// config.setString("backend", "d3d11"); // LET BGFX CHOOSE LIKE test_button_with_png!
|
||||||
config.setBool("vsync", true);
|
config.setBool("vsync", true);
|
||||||
|
// Load textures for sprite buttons (paths relative to project root)
|
||||||
|
config.setString("texture1", "assets/textures/5oxaxt1vo2f91.jpg"); // Car
|
||||||
|
config.setString("texture2", "assets/textures/1f440.png"); // Eyes emoji
|
||||||
|
config.setString("texture3", "assets/textures/IconDesigner.png"); // Icon
|
||||||
m_renderer->setConfiguration(config, m_rendererIO, nullptr);
|
m_renderer->setConfiguration(config, m_rendererIO, nullptr);
|
||||||
}
|
}
|
||||||
|
m_logger->info("✓ Loaded 3 textures for sprite buttons (IDs: 1, 2, 3)");
|
||||||
|
|
||||||
// Create and configure UIModule with inline layout
|
// Create and configure UIModule with inline layout
|
||||||
m_uiModule = std::make_unique<UIModule>();
|
m_uiModule = std::make_unique<UIModule>();
|
||||||
@ -417,14 +495,24 @@ public:
|
|||||||
m_gameIO->publish("input:mouse:wheel", std::move(msg));
|
m_gameIO->publish("input:mouse:wheel", std::move(msg));
|
||||||
}
|
}
|
||||||
else if (e.type == SDL_KEYDOWN) {
|
else if (e.type == SDL_KEYDOWN) {
|
||||||
|
// Only publish special keys (non-printable), printable chars come from SDL_TEXTINPUT
|
||||||
|
int keyCode = e.key.keysym.sym;
|
||||||
|
bool isSpecialKey = (keyCode == SDLK_BACKSPACE || keyCode == SDLK_DELETE ||
|
||||||
|
keyCode == SDLK_RETURN || keyCode == SDLK_LEFT ||
|
||||||
|
keyCode == SDLK_RIGHT || keyCode == SDLK_HOME ||
|
||||||
|
keyCode == SDLK_END || keyCode == SDLK_UP ||
|
||||||
|
keyCode == SDLK_DOWN || keyCode == SDLK_TAB);
|
||||||
|
|
||||||
|
if (isSpecialKey) {
|
||||||
auto msg = std::make_unique<JsonDataNode>("key");
|
auto msg = std::make_unique<JsonDataNode>("key");
|
||||||
msg->setInt("keyCode", e.key.keysym.sym);
|
msg->setInt("keyCode", keyCode);
|
||||||
msg->setInt("scancode", e.key.keysym.scancode);
|
|
||||||
msg->setBool("pressed", true);
|
msg->setBool("pressed", true);
|
||||||
msg->setInt("char", 0);
|
msg->setInt("char", 0);
|
||||||
m_gameIO->publish("input:keyboard", std::move(msg));
|
m_gameIO->publish("input:keyboard", std::move(msg));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else if (e.type == SDL_TEXTINPUT) {
|
else if (e.type == SDL_TEXTINPUT) {
|
||||||
|
// Printable characters come here
|
||||||
auto msg = std::make_unique<JsonDataNode>("key");
|
auto msg = std::make_unique<JsonDataNode>("key");
|
||||||
msg->setInt("keyCode", 0);
|
msg->setInt("keyCode", 0);
|
||||||
msg->setBool("pressed", true);
|
msg->setBool("pressed", true);
|
||||||
@ -500,6 +588,15 @@ private:
|
|||||||
else if (action == "action_danger") {
|
else if (action == "action_danger") {
|
||||||
m_logger->warn("Danger button clicked!");
|
m_logger->warn("Danger button clicked!");
|
||||||
}
|
}
|
||||||
|
else if (action == "sprite_car") {
|
||||||
|
m_logger->info("🚗 Car sprite button clicked! (Texture ID: 1)");
|
||||||
|
}
|
||||||
|
else if (action == "sprite_eyes") {
|
||||||
|
m_logger->info("👀 Eyes sprite button clicked! (Texture ID: 2)");
|
||||||
|
}
|
||||||
|
else if (action == "sprite_icon") {
|
||||||
|
m_logger->info("🎨 Icon sprite button clicked! (Texture ID: 3)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (msg.topic == "ui:click") {
|
else if (msg.topic == "ui:click") {
|
||||||
std::string widgetId = msg.data->getString("widgetId", "");
|
std::string widgetId = msg.data->getString("widgetId", "");
|
||||||
@ -510,10 +607,38 @@ private:
|
|||||||
std::to_string(static_cast<int>(y)) + ")";
|
std::to_string(static_cast<int>(y)) + ")";
|
||||||
}
|
}
|
||||||
else if (msg.topic == "ui:value_changed") {
|
else if (msg.topic == "ui:value_changed") {
|
||||||
|
// Timestamp on receive
|
||||||
|
auto now = std::chrono::high_resolution_clock::now();
|
||||||
|
auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
|
||||||
|
|
||||||
std::string widgetId = msg.data->getString("widgetId", "");
|
std::string widgetId = msg.data->getString("widgetId", "");
|
||||||
if (widgetId == "volume_slider") {
|
if (widgetId == "volume_slider") {
|
||||||
double value = msg.data->getDouble("value", 0);
|
double value = msg.data->getDouble("value", 0);
|
||||||
logEntry = "Volume: " + std::to_string(static_cast<int>(value)) + "%";
|
logEntry = "Volume: " + std::to_string(static_cast<int>(value)) + "%";
|
||||||
|
|
||||||
|
// Extract original timestamp
|
||||||
|
double t0 = msg.data->getDouble("_timestamp_publish", 0);
|
||||||
|
if (t0 > 0) {
|
||||||
|
double latency = (micros - t0) / 1000.0; // ms
|
||||||
|
m_logger->info("⏱️ [T1] Game received ui:value_changed at {} µs (latency from T0: {:.2f} ms)", micros, latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the slider label text
|
||||||
|
auto updateMsg = std::make_unique<JsonDataNode>("set_text");
|
||||||
|
updateMsg->setString("id", "slider_label");
|
||||||
|
updateMsg->setString("text", "Volume: " + std::to_string(static_cast<int>(value)) + "%");
|
||||||
|
|
||||||
|
// Forward original timestamp
|
||||||
|
if (t0 > 0) {
|
||||||
|
updateMsg->setDouble("_timestamp_publish", t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp before publish
|
||||||
|
auto now2 = std::chrono::high_resolution_clock::now();
|
||||||
|
auto micros2 = std::chrono::duration_cast<std::chrono::microseconds>(now2.time_since_epoch()).count();
|
||||||
|
m_logger->info("⏱️ [T2] Game publishing ui:set_text at {} µs (processing time: {:.2f} ms)", micros2, (micros2 - micros) / 1000.0);
|
||||||
|
|
||||||
|
m_gameIO->publish("ui:set_text", std::move(updateMsg));
|
||||||
}
|
}
|
||||||
else if (widgetId.find("chk_") == 0) {
|
else if (widgetId.find("chk_") == 0) {
|
||||||
bool checked = msg.data->getBool("checked", false);
|
bool checked = msg.data->getBool("checked", false);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user