diff --git a/EVEngine b/EVEngine deleted file mode 160000 index 25c0aa96..00000000 --- a/EVEngine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 25c0aa96c9d8094997f61c73d054f285d0f50c38 diff --git a/docs/GUI框架设计.md b/docs/GUI框架设计.md new file mode 100644 index 00000000..930684e5 --- /dev/null +++ b/docs/GUI框架设计.md @@ -0,0 +1,371 @@ +# 声明式游戏 GUI 框架设计 + +> 状态:B 期已完成(脚本 Component + 高级原语);C 期 DevTools 待做。 +> 对外模型:**声明式** retained 组件树 + ECS `UIHost`(非每帧脚本命令式 ImGui)。 +> 后端:Dear ImGui(SDL 输入 + Vulkan 绘制),由 C++ `UISystem` 每帧 walk。 +> ECS 基础库:[sunxfancy/ECS.hpp](https://github.com/sunxfancy/ECS.hpp)(`external/ECS.hpp`)。 + +关联文档:[模块设计.md](./模块设计.md)、[依赖项.md](./依赖项.md)、[整体架构.md](./整体架构.md)、[游戏模型设计.md](./游戏模型设计.md)、[2D渲染API设计.md](./2D渲染API设计.md) +代码落点:[`src/modules/ui/`](../src/modules/ui/)(抽象 [`UIBackend`](../src/modules/ui/UIBackend.h);ImGui 实现 [`ui/imgui/`](../src/modules/ui/imgui/)) + + +## 1. 目标与非目标 + +### 1.1 为何不用脚本每帧命令式 ImGui + +ImGui 本身是即时模式:每帧都要调用 `Begin`/`Button`/`End`。若由 **Squirrel 每帧大量调用**,会把 VM 调度与跨语言边界压在热路径上,也与引擎「状态机 / 热更新 / 数据驱动」及声明式渲染分工不一致。 + +正确分工: + +| 层 | 职责 | 调用频率 | +|----|------|----------| +| 脚本 | **声明** UI 组件树,**在状态变化时**改 props / `setState`,注册事件回调 | 事件驱动,非「每控件一次 ImGui」 | +| C++ `UISystem` | dirty 时 rebuild 保留树;每帧 walk → ImGui | 每渲染帧,全在原生侧 | +| ImGui + SDL/Vulkan | 绘制与输入命中 | 每帧 | + +### 1.2 第一期目标(A) + +- CMake 接入 imgui(核心源 + `imgui_impl_sdl` + `imgui_impl_vulkan`) +- ECS:`UIHost`(Meta + Tree)+ `UISystem` +- 保留节点最小集:`Window` / `Text` / `Button` / 简单 `SameLine` 布局 +- 脚本:`Component` 风格 `build` → `mount` / `setProps` / `setState` / `onClick` +- 帧序:游戏 `RenderSystem` → `UISystem` → `present`(UI overlay) +- 验收:挂一次面板,点按钮改模型文字;脚本无 per-frame UI 调用 + +### 1.3 非目标(第一期不做) + +- 完整 React Virtual DOM / hooks 全家桶(仅 `build` + dirty 整树重建) +- 每个 Button 一个 ECS Entity +- 脚本侧原始 `imgui.begin/button` 热路径(即时模式仅 C++/DevTools 逃生舱) +- 反射自动生成 DevTools 属性面板(C 期) +- 富文本浏览器级布局、游戏内 WYSIWYG 编辑器 + +### 1.4 约定默认 + +| 项 | 默认 | +|----|------| +| React 深度 | 组件化 + `build()` + dirty 重建;不做精细 key-diff | +| ECS 挂载 | Host 为 Entity;控件树在 `Tree` 组件内 | +| 绑定 | props 单向流入;回写仅经事件回调(避免复杂双向 binding) | +| 枚举 | 短 string(≤15);单返回值 | +| 命名空间 | C++ `eve::ui`;脚本 `eve.UI` / `eve.ui` | + + +## 2. 架构与数据流 + +```mermaid +flowchart TB + Script["Squirrel: Component.build / setProps / onClick"] --> ECS["ECS: UIHost + Tree"] + Model["游戏模型"] -.->|"props / 事件回写"| Script + ECS --> UISys["UISystem 每帧"] + UISys -->|"dirty"| Rebuild["Rebuild 保留树"] + UISys --> ImGui["ImGui NewFrame / Widgets / Render"] + ImGui --> Events["交互事件队列"] + Events -->|"仅点击等"| Script + ImGui --> Overlay["Vulkan present overlay"] + RenderSys["RenderSystem 世界绘制"] --> Present["Graphics::present"] + Overlay --> Present +``` + +### 2.1 职责切分 + +| 组件 | 职责 | 不负责 | +|------|------|--------| +| `UIHost` Entity | 一块屏幕/面板挂载点(可见性、layer、modal) | 玩法逻辑 | +| `Tree` 组件 | 保留 `UINode` 数组 + dirty + handler 表 | Vulkan | +| `UISystem` | rebuild、每帧 walk → ImGui、收集事件 | 游戏规则 | +| `UIBackend` | 输入/呈现抽象;默认 `createImGuiBackend()` | 控件树语义 | +| `ui/imgui/ImGuiBackend` | SDL 输入、Vulkan present overlay | 脚本 API | +| `graphics` | present 前调用 overlay 钩子 | UI 树语义 | + +### 2.2 与交互层(游戏模型)的关系 + +对齐 [游戏模型设计.md](./游戏模型设计.md) 四层模型中的 **交互层**: + +- UI 是 ViewModel 的可视化:改数据 → 改显示;用户操作 → 事件 → 改模型 +- 交互对象可作为 component 挂到模型实体上(`UIHost`),与声明式渲染同表 + +### 2.3 与声明式 2D 渲染的同构 + +| 渲染 | GUI | +|------|-----| +| `Renderable2D` + `Transform2D`/`Sprite` | `UIHost` + `Meta`/`Tree` | +| `RenderSystem::render` | `UISystem::render`(`View`,含继承子类) | +| 脚本改组件字段 | `select(name)` 后 `setText` / 或直接改 `host->tree()` | +| 禁止 per-sprite `draw` | 禁止 per-frame `imgui.button` | + +**ECS 约定:** + +- 每个面板一个 `UIHost` 实体;`Meta.name` 用于 `findHost` / `select` / `mountBuildAs` +- 可 `class Hud : public UIHost` 挂玩法组件;仍被 `View` 扫到 +- `Meta.ownerId` 把 UI 挂到游戏实体 id(`bindOwner` / `findHostByOwner`) +- 点击全局队列:`consumeClick()` → `"hostName/nodeId"` +- 控件仍在 `Tree` 内(不做每 Button 一 Entity) + + + +## 3. ECS 组件 + +```cpp +namespace eve::ui { + +enum class NodeType : uint8_t { Window, Text, Button, SameLine, Group }; + +struct UINode { + NodeType type = NodeType::Text; + std::string id; // 稳定 id(事件路由) + std::string text; // 标题 / 标签 + bool visible = true; + int firstChild = -1; + int nextSibling = -1; + uint32_t handlerClick = 0; // 0 = none;索引进 Host handler 表 +}; + +class UIHost : public ecs::Entity { +public: + ENTITY(UIHost, ecs::Entity) + void release() override; + + struct Meta { + bool visible = true; + int layer = 0; + bool modal = false; + UIHost *entity = nullptr; + }; + + struct Tree { + bool dirty = true; + std::vector nodes; + int root = -1; + // 脚本/C++ 回调槽;实现侧可用 std::function 或 squirrel 句柄 + }; + + COMPONENT(Meta, meta) + COMPONENT(Tree, tree) +}; + +class UISystem { +public: + static void beginFrame(); // ImGui NewFrame(SDL 事件已泵) + static void render(); // walk hosts → widgets → ImGui::Render + static void dispatchEvents(); // 帧末把点击投递给脚本 +}; + +} // namespace eve::ui +``` + +规则: + +1. **不**为每个 Button 建 Entity;Host 少、树在组件内,利于 ImGui 连续 walk。 +2. Dirty:`setProps` / `setState` / 结构变化 → `tree.dirty = true`;rebuild 整棵 Host 树(A 期)。 +3. 多个 Host:`ecs::View`,按 `layer` 排序后绘制。 + + +## 4. 可组合声明式 API + +### 4.1 C++(`WidgetDesc`) + +```cpp +#include "ui/Widget.h" +using namespace eve::ui; + +ui->mount(window("Inventory", { + text("Hello", "label"), + group({ + button("Use", "use", [&]{ /* ... */ }), + sameLine(), + button("Drop", "drop"), + }), +})); +ui->setText("label", "Ready"); +``` + +### 4.2 脚本(栈式 builder,挂一次) + +```squirrel +ui.beginBuild() +ui.beginWindow("Inventory", "root") + ui.text("Ready", "status") + ui.beginGroup("actions") + ui.button("Use", "use") + ui.sameLine("") + ui.button("Drop", "drop") + ui.end() +ui.end() +ui.mountBuild() + +// 状态变化时改 props(非每帧重建) +ui.setText("status", "Used") + +// 点击:dispatchEvents 之后 +local id = ui.consumeClick() +while (id != "") { + if (id == "use") { /* 改模型 */ } + id = ui.consumeClick() +} +``` + +| 概念 | 含义 | +|------|------| +| `WidgetDesc` / builder | 可任意嵌套 Window/Text/Button/Group/SameLine | +| `id` | 稳定键;`setText` / `setVisible` / 点击路由 | +| `mount` / `mountBuild` | 声明一次;后续只改 props | +| `onClick`(C++) / `consumeClick`(脚本) | 仅交互时处理 | +### 4.3 C++ Component(B) + +```cpp +class InventoryPanel : public eve::ui::Component { + std::vector items{"Sword"}; + int gold = 10; + WidgetDesc build() override { + return window("Inv", { + text("Gold " + std::to_string(gold), "gold"), + listButtons("items", items), + when(gold > 0, button("Buy", "buy")), + }); + } +}; + +InventoryPanel panel; +panel.mountAs("inv"); +panel.gold = 9; +panel.markDirty(); +panel.updateIfDirty(); // key reconcile when structure stable +``` + +### 4.4 脚本 List / 主题(B) + +```squirrel +ui.beginBuild() +ui.beginWindow("Shop", "root") + ui.beginList("goods") + ui.listItem("Apple", "goods/0") + ui.listItem("Bread", "goods/1") + ui.end() + ui.separator("s") + ui.checkbox("Member", false, "mem") +ui.end() +ui.mountBuildAs("shop") +ui.setThemeDark() +ui.setNavKeyboard(true) +``` + +### 4.5 脚本 Component(B) + +```squirrel +class ShopPanel extends eve.UIComponent { + gold = 10 + constructor(uiRef) { + base.constructor(uiRef) + gold = 10 + } + function build() { + local u = ui() + u.beginWindow("Shop", "root") + u.text("Gold " + gold, "gold") + u.slider("Tax", 0.1, 0.0, 1.0, "tax") + u.progress(gold / 100.0, "bar", "") + if (gold > 0) u.button("Buy", "buy") + u.end() + } +} + +local panel = ShopPanel(ui) +panel.mountAs("shop") +// 状态变化时: +panel.gold = 9 +panel.setState() +panel.updateIfDirty() // remountBuildAs + key reconcile +``` + +`eve.Component` 是 `eve.UIComponent` 的别名。 + + + +## 5. 保留树 vs ImGui + +1. `WidgetDesc`(脚本构建)→ flatten 为 `UINode` arena。 +2. 每帧:`UISystem` 只读节点调 `ImGui::*`,**不进入 Squirrel**(除非本帧有待派发事件)。 +3. 命中控件后推入 `Event{host, nodeId, kind}`,在 `dispatchEvents` 调脚本。 +4. B 期:列表 `key`、局部 dirty。 + + +## 6. 帧生命周期 + +```mermaid +sequenceDiagram + participant Boot as Boot_Loop + participant Ev as Event_pump + participant Logic as eve_update + participant R as RenderSystem + participant U as UISystem + participant G as Graphics_present + + Boot->>Ev: pump SDL + Ev->>U: ImGui_ImplSDL2_ProcessEvent + Boot->>Logic: update dt + Boot->>R: world draw + Boot->>U: beginFrame + render + Boot->>G: present overlay ImGui draw data + Boot->>U: dispatchEvents +``` + +规则: + +1. SDL 事件在 `event.pump` 时交给 ImGui(若 UI 模块已初始化)。 +2. `WantCaptureMouse/Keyboard` 为真时,游戏逻辑可选择忽略对应输入。 +3. Overlay 在 swapchain render pass 内、`endRenderPass` 之前绘制。 +4. modal Host 依赖 ImGui 焦点与捕获标志挡住下层。 + + +## 7. 模块与依赖 + +- 模块名:`ui`(C++ `eve::ui::UI`,脚本 `eve.UI`) +- 依赖:Window(SDL)、Graphics(Vulkan present 钩子)、ECS.hpp、imgui +- third-party:补齐 imgui 头文件;CMake 编 `imgui` 静态库并安装头路径 + + +## 8. 分期 + +### A — 最小可玩(进行中) + +1. imgui CMake + SDL/Vulkan backend +2. `UIHost` / `Tree` / `UISystem`;Window / Text / Button +3. Graphics present overlay 钩子 +4. 脚本 mount / setProps / onClick;C++ 验收测试 + +### B — 组合与列表 + +- [x] `List` / `listButtons` / 脚本 `beginList`+`listItem` +- [x] 条件子树 `when` / `whenElse` +- [x] `Component`(C++ `build` / `setState` / `rebuild` / key reconcile) +- [x] 按 key 局部 dirty(`setTreeReconcile` / `applyTreeReconcile`) +- [x] 主题 `Theme` + `setThemeDark` / `setThemeLight`;键盘导航开关 +- [x] 原语扩展:`Separator` / `Checkbox` +- [x] 脚本侧 `class X extends eve.UIComponent { build() }`(`eve.Component` 别名) +- [x] 高级原语:`Slider` / `Progress` / `InputText` / `CollapsingHeader` / `Child` +- [x] `consumeChange` / `setValue` / `WantCapture*` / `setHostModal` + +### C — DevTools + +- 同一 `UISystem`;反射属性面板(见 [界面设计.md](./界面设计.md)) +- 原始 ImGui 逃生舱仅限 C++ DevTools + + +## 9. 验收标准(第一期) + +- [x] 工程可编译并链接 imgui;`#include ` +- [x] C++ 创建 `UIHost`,树含 Window+Text+Button;`UISystem` 每帧画出路径存在;**无**脚本 per-frame UI 循环(见 `test/UISystem.cpp`) +- [x] 点击 Button 触发回调并改 Text(C++ handler + `dispatchEvents`) +- [x] `setProps` / `setLabelText` 后保留树字段更新 +- [x] 与 boot 帧序对接(`src/scripts/load.nut`) +- [x] 与现有 `RenderSystem` 同帧 Vulkan overlay 实机冒烟(`test/UIOverlaySmoke.cpp`) + + +## 10. 内部逃生舱 + +| API | 用途 | +|-----|------| +| 直接 `ImGui::*`(C++) | DevTools、调试 | +| `UISystem::render` | 游戏主路径 | +| 脚本 `imgui.*` | **不提供**为热路径 | diff --git a/docs/依赖项.md b/docs/依赖项.md index bad0ecdb..3e8648bd 100644 --- a/docs/依赖项.md +++ b/docs/依赖项.md @@ -10,7 +10,7 @@ medialoader - 把love引擎中的图像载入跟声音载入单独扒下来的 SDL2 - 核心窗口、消息、各种input freetype - 字体 glm - 核心数学库 -imgui - 基础图像gui +imgui - 基础图像 gui(Dear ImGui v1.83);声明式游戏 UI 后端(见 [GUI框架设计.md](./GUI框架设计.md))。源码在 `third-party/imgui/`,需由 third-party CMake 编成静态库并暴露头文件;引擎侧 `ui` 模块使用 `imgui_impl_sdl` + `imgui_impl_vulkan` openal - 用来实现3d立体声 physfs - love使用的文件系统,这里也抄来用做文件系统 Box2D - 物理引擎 diff --git a/docs/整体架构.md b/docs/整体架构.md index 3e2fabf4..bf9389c7 100644 --- a/docs/整体架构.md +++ b/docs/整体架构.md @@ -57,12 +57,13 @@ EVEngine整体架构 - [ ] math 数学矩阵、向量、随机化、噪声 - [x] mouse 鼠标 - [ ] network 网络接口 - - [ ] particles 粒子系统 + - [x] particles 粒子系统 - [ ] plugins dll载入与简单的插件 - [ ] system 系统时间、休眠等 - [x] sound 音频资源载入 - [ ] thread 线程池、task manager、GPU compute - [x] touch 触屏接口 + - [ ] ui 声明式 GUI(ImGui retained + UIHost,见 GUI框架设计.md) - [x] window 窗口控制 - scripts shader代码、squirrel初始化脚本 - test 各种单元测试 diff --git a/docs/模块设计.md b/docs/模块设计.md index dc9d126e..cd7d668d 100644 --- a/docs/模块设计.md +++ b/docs/模块设计.md @@ -29,7 +29,7 @@ - [x] 统一文件系统访问(physfs / 抽象层) - [x] 目录查看、文件读写 - [ ] 文件增删改查完整 API -- [ ] 监视文件变动(热重载依赖) +- [x] 监视文件变动(抽象 `watch`/`pollWatch`;physfs + Poco DirectoryWatcher) - [ ] 打包/挂载 zip 等归档 ### 2. 事件系统 — `event` @@ -148,11 +148,19 @@ Love2D 风格即时模式仅作 C++/DevTools 内部逃生舱。 ### 12. 粒子系统 — `particles` -创建、渲染、加载与配置粒子效果(后期)。 +创建、渲染、加载与配置粒子效果。脚本配置 `ParticleEmitter` 组件;每帧由 ECS System 驱动。 -- [ ] 粒子发射器与生命周期 -- [ ] 与 `graphics` 的批次渲染 -- [ ] 资源 / 配置加载与热更新 +- [x] 粒子发射器与生命周期(ECS `ParticleEmitter`:Config / Sim / Draw) +- [x] `ParticleSimSystem::update` + `ParticleRenderSystem::render`(与 Module 解耦) +- [x] 与 `graphics` 的批次渲染 +- [x] 基础预设配置(`applyPreset`: spark / smoke / fire) +- [x] 资源文件配置加载与热更新(JSON + `Resource` + `ParticleConfigSystem::poll`) + +代码:[`src/modules/particles/`](../src/modules/particles/) +示例配置:[`example/particles/fire.json`](../example/particles/fire.json) +帧序约定:`ParticleConfigSystem` → `ParticleSimSystem` → `RenderSystem` → `ParticleRenderSystem` → UI → present。 + +**JSON 字段(对象根):** `buffer`(仅创建时)、`preset`、`x`/`y`、`emissionRate`、`particleLifetime`/`lifeMin`·`lifeMax`、`emitterLife`、`direction`、`spread`、`speed`、`linearAcceleration`、`particleSize`、`sizes`、`colorStart`/`colorEnd`、`layer`、`visible`、`texture`、`autoStart`、`autoReload`。 ### 13. Box2D 物理 — `box2d` @@ -212,6 +220,19 @@ Love2D 风格即时模式仅作 C++/DevTools 内部逃生舱。 - [ ] 与 snapshot / 存档序列化对接 - [ ] `entity` 模块封装(可选,对上游薄封装) +### 19. 声明式 GUI — `ui` + +在 ImGui 之上的保留模式游戏 UI(React 式 `build` + dirty,挂 ECS `UIHost`)。脚本不每帧调用 ImGui。 +**设计文档:** [GUI框架设计.md](./GUI框架设计.md)。 + +- [x] imgui 静态库接入(主工程 `eve_imgui` + third-party CMake)与 SDL/Vulkan backend +- [x] `UIHost` / `Tree` + `UISystem`(Window / Text / Button) +- [x] Graphics present overlay 钩子 +- [x] 脚本 `mountSimple` / `setLabelText` / `setButtonSetsLabel`(Component 完整形态待 B) +- [x] 与 boot 帧序对接(`load.nut`:`UISystem` → `present` → `dispatchEvents`) +- [x] B:List / 局部 dirty / 主题 / C++ Component.build(脚本 Component 类待续) +- [ ] C:DevTools 反射属性面板 + ## 进度总览 @@ -233,10 +254,11 @@ Love2D 风格即时模式仅作 C++/DevTools 内部逃生舱。 | 网络 | `network` | [ ] | | 线程 | `thread` | [ ] | | GPU 计算 | `compute` | [ ] | -| 粒子 | `particles` | [ ] | +| 粒子 | `particles` | [x] 一期可用 | | 物理 | `box2d` | [ ] | | 动画 | `animation` | [ ] | | 地图 | `map` | [ ] | | 数据库 | `database` | [ ] | | 数学 | `math` | [ ] | | 实体 | `entity` | [x] ECS.hpp 已接入(脚本绑定待做) | +| 声明式 GUI | `ui` | [x] A+B(List/Component/Theme;脚本 Component 类待续) | diff --git a/docs/测试覆盖.md b/docs/测试覆盖.md index 3026fe24..173c4029 100644 --- a/docs/测试覆盖.md +++ b/docs/测试覆盖.md @@ -124,7 +124,7 @@ | network | `src/modules/network/Network.h` | 可用 | 已展开 | **见 §15** | `test/network.cpp`(9 network.*) | | thread | `src/modules/thread/Thread.h` | 部分 | 待展开 | - | | | compute | — | 未实现 | N/A | - | | -| particles | — | 未实现 | N/A | - | | +| particles | `src/modules/particles/` | 可用 | 已展开 | JSON 配置 + 热更新 | `test/particles.cpp`(15 用例) | | box2d | — | 未实现 | N/A | - | | | animation | — | 未实现 | N/A | - | | | map | — | 未实现 | N/A | - | | diff --git a/example/particles/fire.json b/example/particles/fire.json new file mode 100644 index 00000000..6e543e9a --- /dev/null +++ b/example/particles/fire.json @@ -0,0 +1,21 @@ +{ + "buffer": 256, + "preset": "fire", + "x": 400, + "y": 500, + "emissionRate": 60, + "particleLifetime": [0.4, 1.0], + "emitterLife": -1, + "direction": -1.5708, + "spread": 0.5, + "speed": [20, 80], + "linearAcceleration": [-15, -80, 15, -20], + "particleSize": [10, 10], + "sizes": [1.2, 0.3], + "colorStart": [1.0, 0.7, 0.1, 1.0], + "colorEnd": [1.0, 0.1, 0.0, 0.0], + "layer": 2, + "visible": true, + "autoStart": true, + "autoReload": true +} diff --git a/example/ui_component.nut b/example/ui_component.nut new file mode 100644 index 00000000..e322c061 --- /dev/null +++ b/example/ui_component.nut @@ -0,0 +1,50 @@ +// Declarative UIComponent demo (use as main.nut). + +class HudPanel extends eve.UIComponent { + hp = 100 + volume = 0.5 + constructor(uiRef) { + base.constructor(uiRef) + hp = 100 + volume = 0.5 + } + function build() { + local u = ui() + u.beginWindow("HUD", "root") + u.text("HP " + hp, "hp") + u.progress(hp / 100.0, "bar", hp + "%") + u.slider("Vol", volume, 0.0, 1.0, "vol") + u.button("Hurt", "hurt") + u.end() + } +} + +eve_init = function() { + hud <- HudPanel(ui) + hud.mountAs("hud") +}; + +eve_update = function(dt) { + local c = ui.consumeClick(); + while (c != "") { + if (c == "hud/hurt") { + hud.hp -= 10; + if (hud.hp < 0) hud.hp = 0; + hud.setState(); + hud.updateIfDirty(); + } + c = ui.consumeClick(); + } + local ch = ui.consumeChange(); + while (ch != "") { + if (ch == "hud/vol") { + hud.volume = ui.getValue("vol"); + } + ch = ui.consumeChange(); + } +}; + +eve_render = function() { + gfx.clear(); + ui.beginFrameAndRender(); +}; diff --git a/example/ui_demo.nut b/example/ui_demo.nut new file mode 100644 index 00000000..1eebc2da --- /dev/null +++ b/example/ui_demo.nut @@ -0,0 +1,23 @@ +// Multi-host ECS UI demo (use as main.nut). + +eve_init = function() { + ui.beginBuild(); + ui.beginWindow("Inventory", "root"); + ui.text("Ready", "status"); + ui.button("Use", "use"); + ui.end(); + ui.mountBuildAs("inv"); +}; + +eve_update = function(dt) { + local c = ui.consumeClick(); + while (c != "") { + if (c == "inv/use") ui.setText("status", "Used"); + c = ui.consumeClick(); + } +}; + +eve_render = function() { + gfx.clear(); + ui.beginFrameAndRender(); +}; diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index f28bc7b0..9a929195 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -112,9 +112,9 @@ else() endif() if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android") OR CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) - set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio) + set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles) else() - set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio) + set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVTouch EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles) endif() if(EVENGINE_PLATFORM_LIB) diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index 06fe3e96..ff3aed95 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -1,5 +1,6 @@ create_module(EVFileSystem filesystem) +target_compile_definitions(EVFileSystem PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) create_module(EVGraphics graphics) create_module(EVWindow window) create_module(EVEvent event) @@ -21,4 +22,29 @@ target_include_directories(EVSound PRIVATE ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs/libogg/include ) create_module(EVAudio audio) -target_compile_definitions(EVAudio PRIVATE AL_LIBTYPE_STATIC) \ No newline at end of file +target_compile_definitions(EVAudio PRIVATE AL_LIBTYPE_STATIC) + +# Dear ImGui (compiled from third-party sources; also installed by third-party ExternalProject) +set(_eve_imgui_sources + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui.cpp + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_draw.cpp + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_tables.cpp + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_widgets.cpp + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_impl_sdl.cpp + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_impl_vulkan.cpp +) +add_library(eve_imgui STATIC ${_eve_imgui_sources}) +target_include_directories(eve_imgui PUBLIC ${CMAKE_SOURCE_DIR}/third-party/imgui) +# imgui_impl_sdl includes ; vendored install puts headers under include/SDL2/. +target_include_directories(eve_imgui PRIVATE + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include/SDL2 + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include/SDL2 +) +# Avoid pulling imgui_demo.cpp (ShowFontAtlas etc.) into every link. +target_compile_definitions(eve_imgui PUBLIC IMGUI_DISABLE_DEMO_WINDOWS) + +create_module(EVUI ui) +target_link_libraries(EVUI PUBLIC eve_imgui) +target_include_directories(EVUI PUBLIC ${CMAKE_SOURCE_DIR}/third-party/imgui) + +create_module(EVParticles particles) \ No newline at end of file diff --git a/src/modules/Readme.md b/src/modules/Readme.md index 88faa766..c7cb6821 100644 --- a/src/modules/Readme.md +++ b/src/modules/Readme.md @@ -20,6 +20,9 @@ 1. 文件系统 - filesystem 封装了核心的文件系统功能,提供如下功能:统一的文件系统访问、目录查看修改、监视文件变动、文件读取 + 主实现:physfs;cppfs::File 仅用于 OS 绝对路径特例 + watch API(抽象虚函数):`watch` / `unwatch` / `pollWatch` / `getLastWatchPath` + watch 实现:physfs 后端 + Poco DirectoryWatcher(FileWatch) 2. 事件系统 - event 主要处理多线程间的信号同步与各种回调事件,处理来自硬件的各种信号 @@ -52,7 +55,9 @@ 负责线程、线程池的管理,提交异步任务 10. 粒子系统 - particles - 负责创建、渲染、加载设置等关于粒子系统的设置 + ECS:`ParticleEmitter`(Config/Sim/Draw/Resource) + System:`ParticleConfigSystem`(filesystem watch + mtime 回退)/ `ParticleSimSystem` / `ParticleRenderSystem` + Module:工厂、`newEmitterFromFile`、脚本绑定;`update` 含 poll+sim 11. Box2d物理 - box2d 负责管理box2d物理引擎 diff --git a/src/modules/event/sdl/Event.cpp b/src/modules/event/sdl/Event.cpp index e3ea68e6..3135f652 100644 --- a/src/modules/event/sdl/Event.cpp +++ b/src/modules/event/sdl/Event.cpp @@ -11,6 +11,7 @@ #include "keyboard/Keyboard.h" #include "joystick/Joystick.h" #include "joystick/Pad.h" +#include "ui/UI.h" #include "common/Exception.h" #include "common/Module.h" #include "common/config.h" @@ -132,6 +133,8 @@ void Event::pump() while (SDL_PollEvent(&e)) { + if (auto *ui = eve::ModuleManager::getInstance("UI")) + ui->processEvent(&e); Message *msg = convert(e); if (msg) push(msg); diff --git a/src/modules/filesystem/FileWatch.cpp b/src/modules/filesystem/FileWatch.cpp new file mode 100644 index 00000000..4b71c7af --- /dev/null +++ b/src/modules/filesystem/FileWatch.cpp @@ -0,0 +1,234 @@ +#include "filesystem/FileWatch.h" + +#include +#include +#include + +#include + +namespace eve::filesystem { +namespace { + +std::string basenameOf(const std::string &path) { + Poco::Path p(path); + return p.getFileName(); +} + +std::string normalizeDir(const std::string &dir) { + // Resolve symlinks (/var → /private/var on macOS) so watch keys match event paths. + char *resolved = realpath(dir.c_str(), nullptr); + std::string s; + if (resolved) { + s = resolved; + free(resolved); + } else { + Poco::Path p(dir); + p.makeAbsolute(); + s = p.toString(); + } + while (s.size() > 1 && (s.back() == '/' || s.back() == '\\')) s.pop_back(); + return s; +} + +} // namespace + +struct FileWatch::DirWatch { + std::string realDir; + std::unique_ptr watcher; + std::unordered_map filters; // filter → reportPath + int refs = 0; +}; + +FileWatch::FileWatch() = default; + +FileWatch::~FileWatch() { clear(); } + +bool FileWatch::add(const std::string &realDir, const std::string &filterName, + const std::string &reportPath, int scanInterval) { + if (realDir.empty() || reportPath.empty()) return false; + const std::string dir = normalizeDir(realDir); + try { + Poco::File f(dir); + if (!f.exists() || !f.isDirectory()) return false; + } catch (...) { + return false; + } + + std::lock_guard lock(mu_); + + auto prev = reportToDir_.find(reportPath); + if (prev != reportToDir_.end()) { + auto it = byDir_.find(prev->second); + if (it != byDir_.end()) { + DirWatch *dw = it->second.get(); + for (auto fit = dw->filters.begin(); fit != dw->filters.end();) { + if (fit->second == reportPath) { + fit = dw->filters.erase(fit); + --dw->refs; + } else { + ++fit; + } + } + if (dw->refs <= 0) { + if (dw->watcher) { + dw->watcher->itemAdded -= Poco::delegate(this, &FileWatch::onAdded); + dw->watcher->itemRemoved -= Poco::delegate(this, &FileWatch::onRemoved); + dw->watcher->itemModified -= Poco::delegate(this, &FileWatch::onModified); + dw->watcher->itemMovedFrom -= Poco::delegate(this, &FileWatch::onMovedFrom); + dw->watcher->itemMovedTo -= Poco::delegate(this, &FileWatch::onMovedTo); + } + byDir_.erase(it); + } + } + reportToDir_.erase(prev); + } + + DirWatch *dw = nullptr; + auto it = byDir_.find(dir); + if (it == byDir_.end()) { + auto owned = std::make_unique(); + owned->realDir = dir; + try { + owned->watcher = std::make_unique( + dir, Poco::DirectoryWatcher::DW_FILTER_ENABLE_ALL, scanInterval); + } catch (...) { + return false; + } + owned->watcher->itemAdded += Poco::delegate(this, &FileWatch::onAdded); + owned->watcher->itemRemoved += Poco::delegate(this, &FileWatch::onRemoved); + owned->watcher->itemModified += Poco::delegate(this, &FileWatch::onModified); + owned->watcher->itemMovedFrom += Poco::delegate(this, &FileWatch::onMovedFrom); + owned->watcher->itemMovedTo += Poco::delegate(this, &FileWatch::onMovedTo); + dw = owned.get(); + byDir_[dir] = std::move(owned); + } else { + dw = it->second.get(); + } + + dw->filters[filterName] = reportPath; + ++dw->refs; + reportToDir_[reportPath] = dir; + return true; +} + +bool FileWatch::remove(const std::string &reportPath) { + std::lock_guard lock(mu_); + auto prev = reportToDir_.find(reportPath); + if (prev == reportToDir_.end()) return false; + const std::string dir = prev->second; + reportToDir_.erase(prev); + + auto it = byDir_.find(dir); + if (it == byDir_.end()) return false; + DirWatch *dw = it->second.get(); + for (auto fit = dw->filters.begin(); fit != dw->filters.end();) { + if (fit->second == reportPath) { + fit = dw->filters.erase(fit); + --dw->refs; + } else { + ++fit; + } + } + if (dw->refs <= 0) { + if (dw->watcher) { + dw->watcher->itemAdded -= Poco::delegate(this, &FileWatch::onAdded); + dw->watcher->itemRemoved -= Poco::delegate(this, &FileWatch::onRemoved); + dw->watcher->itemModified -= Poco::delegate(this, &FileWatch::onModified); + dw->watcher->itemMovedFrom -= Poco::delegate(this, &FileWatch::onMovedFrom); + dw->watcher->itemMovedTo -= Poco::delegate(this, &FileWatch::onMovedTo); + } + byDir_.erase(it); + } + return true; +} + +void FileWatch::clear() { + std::lock_guard lock(mu_); + for (auto &kv : byDir_) { + DirWatch *dw = kv.second.get(); + if (dw && dw->watcher) { + dw->watcher->itemAdded -= Poco::delegate(this, &FileWatch::onAdded); + dw->watcher->itemRemoved -= Poco::delegate(this, &FileWatch::onRemoved); + dw->watcher->itemModified -= Poco::delegate(this, &FileWatch::onModified); + dw->watcher->itemMovedFrom -= Poco::delegate(this, &FileWatch::onMovedFrom); + dw->watcher->itemMovedTo -= Poco::delegate(this, &FileWatch::onMovedTo); + } + } + byDir_.clear(); + reportToDir_.clear(); + queue_.clear(); +} + +int FileWatch::count() const { + std::lock_guard lock(mu_); + return int(reportToDir_.size()); +} + +bool FileWatch::poll(Event &out) { + std::lock_guard lock(mu_); + if (queue_.empty()) return false; + out = std::move(queue_.front()); + queue_.erase(queue_.begin()); + return true; +} + +void FileWatch::handlePocoEvent(const std::string &kind, const std::string &itemPath) { + const std::string name = basenameOf(itemPath); + Poco::Path parent(itemPath); + parent.makeParent(); + const std::string dir = normalizeDir(parent.toString()); + + std::lock_guard lock(mu_); + auto it = byDir_.find(dir); + if (it == byDir_.end()) { + for (auto &kv : byDir_) { + if (itemPath.size() >= kv.first.size() && itemPath.compare(0, kv.first.size(), kv.first) == 0) { + it = byDir_.find(kv.first); + break; + } + } + } + if (it == byDir_.end()) return; + DirWatch *dw = it->second.get(); + + auto push = [&](const std::string &report) { + Event ev; + ev.kind = kind; + ev.path = report; + ev.realPath = itemPath; + queue_.push_back(std::move(ev)); + }; + + auto all = dw->filters.find(""); + if (all != dw->filters.end()) { + std::string report = all->second; + if (!report.empty() && report.back() != '/') report += "/"; + report += name; + push(report); + } + + auto fit = dw->filters.find(name); + if (fit != dw->filters.end()) push(fit->second); +} + +void FileWatch::onAdded(const void *, const Poco::DirectoryWatcher::DirectoryEvent &event) { + handlePocoEvent("added", event.item.path()); +} + +void FileWatch::onRemoved(const void *, const Poco::DirectoryWatcher::DirectoryEvent &event) { + handlePocoEvent("removed", event.item.path()); +} + +void FileWatch::onModified(const void *, const Poco::DirectoryWatcher::DirectoryEvent &event) { + handlePocoEvent("modified", event.item.path()); +} + +void FileWatch::onMovedFrom(const void *, const Poco::DirectoryWatcher::DirectoryEvent &event) { + handlePocoEvent("movedFrom", event.item.path()); +} + +void FileWatch::onMovedTo(const void *, const Poco::DirectoryWatcher::DirectoryEvent &event) { + handlePocoEvent("movedTo", event.item.path()); +} + +} // namespace eve::filesystem diff --git a/src/modules/filesystem/FileWatch.h b/src/modules/filesystem/FileWatch.h new file mode 100644 index 00000000..e97decc8 --- /dev/null +++ b/src/modules/filesystem/FileWatch.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace eve::filesystem { + +/** + * OS directory watch (Poco::DirectoryWatcher) with a main-thread event queue. + * Watch a directory (all children) or a single file (parent dir + name filter). + */ +class FileWatch { +public: + struct Event { + std::string kind; // "added" | "removed" | "modified" | "movedFrom" | "movedTo" + std::string path; // report path (usually the path passed to watch()) + std::string realPath; + }; + + FileWatch(); + ~FileWatch(); + + FileWatch(const FileWatch &) = delete; + FileWatch &operator=(const FileWatch &) = delete; + + /** + * @param realDir Absolute OS directory to watch. + * @param filterName Empty = all entries; otherwise only this basename. + * @param reportPath Path reported in Event::path (virtual / user path). + * @param scanInterval Seconds for platforms that poll (Darwin MODIFIED). + */ + bool add(const std::string &realDir, const std::string &filterName, const std::string &reportPath, + int scanInterval = 1); + + /** Remove by reportPath previously passed to add(). */ + bool remove(const std::string &reportPath); + void clear(); + + int count() const; + + /** Pop one event; false if empty. */ + bool poll(Event &out); + +private: + struct DirWatch; + + void onAdded(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); + void onRemoved(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); + void onModified(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); + void onMovedFrom(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); + void onMovedTo(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); + + void handlePocoEvent(const std::string &kind, const std::string &itemPath); + + mutable std::mutex mu_; + std::vector queue_; + std::unordered_map> byDir_; + std::unordered_map reportToDir_; +}; + +} // namespace eve::filesystem diff --git a/src/modules/filesystem/Filesystem.cpp b/src/modules/filesystem/Filesystem.cpp index 12e1fb0c..7f07738a 100644 --- a/src/modules/filesystem/Filesystem.cpp +++ b/src/modules/filesystem/Filesystem.cpp @@ -65,6 +65,13 @@ void Filesystem::expose(ssq::Class &cls) { cls.addFunc("getCRequirePath", &Filesystem::getCRequirePath); cls.addFunc("getRequirePath", &Filesystem::getRequirePath); cls.addFunc("isRealDirectory", &Filesystem::isRealDirectory); + cls.addFunc("watch", &Filesystem::watch); + cls.addFunc("unwatch", &Filesystem::unwatch); + cls.addFunc("unwatchAll", &Filesystem::unwatchAll); + cls.addFunc("getWatchCount", &Filesystem::getWatchCount); + cls.addFunc("pollWatch", &Filesystem::pollWatch); + cls.addFunc("getLastWatchPath", &Filesystem::getLastWatchPath); + cls.addFunc("getLastWatchRealPath", &Filesystem::getLastWatchRealPath); } FileData *Filesystem::newFileData(const void *data, std::string filename, size_t size) const { diff --git a/src/modules/filesystem/Filesystem.h b/src/modules/filesystem/Filesystem.h index d9aa4dc8..8b942675 100644 --- a/src/modules/filesystem/Filesystem.h +++ b/src/modules/filesystem/Filesystem.h @@ -219,9 +219,31 @@ public: **/ virtual std::string getExecutablePath() const; + // --- File watch (backend-specific; main-thread poll) --- + + /** + * Watch a virtual or absolute OS path (file or directory). + * File watches monitor the parent directory and filter by basename. + * Returns false if the path cannot be resolved to a real directory. + **/ + virtual bool watch(std::string path) = 0; + + /** Stop watching a path previously passed to watch(). */ + virtual bool unwatch(std::string path) = 0; + virtual void unwatchAll() = 0; + virtual int getWatchCount() const = 0; + + /** + * Pop next watch event kind: "added"|"removed"|"modified"|"movedFrom"|"movedTo". + * Empty string if queue empty. Path available via getLastWatchPath(). + **/ + virtual std::string pollWatch() = 0; + virtual std::string getLastWatchPath() const = 0; + virtual std::string getLastWatchRealPath() const = 0; + private: // Should we save external or internal for Android - bool useExternal; + bool useExternal = false; }; // Filesystem } // namespace eve::filesystem diff --git a/src/modules/filesystem/physfs/Filesystem.cpp b/src/modules/filesystem/physfs/Filesystem.cpp index 6075cff4..a15872f7 100644 --- a/src/modules/filesystem/physfs/Filesystem.cpp +++ b/src/modules/filesystem/physfs/Filesystem.cpp @@ -1,28 +1,10 @@ -/** - * Copyright (c) 2006-2021 LOVE Development Team - * - * This software is provided 'as-is', without any express or implied - * warranty. In no event will the authors be held liable for any damages - * arising from the use of this software. - * - * Permission is granted to anyone to use this software for any purpose, - * including commercial applications, and to alter it and redistribute it - * freely, subject to the following restrictions: - * - * 1. The origin of this software must not be misrepresented; you must not - * claim that you wrote the original software. If you use this software - * in a product, an acknowledgment in the product documentation would be - * appreciated but is not required. - * 2. Altered source versions must be plainly marked as such, and must not be - * misrepresented as being the original software. - * 3. This notice may not be removed or altered from any source distribution. - **/ - #include "filesystem/physfs/Filesystem.h" #include #include +#include #include +#include #include "filesystem/physfs/File.h" #include "common/b64.h" @@ -94,6 +76,8 @@ Filesystem::Filesystem() : fused(false), fusedSet(false) { } Filesystem::~Filesystem() { + unwatchAll(); + #ifdef EVENGINE_ANDROID android::deinitializeVirtualArchive(); #endif @@ -588,6 +572,140 @@ void Filesystem::allowMountingForPath(const std::string &path) { allowedMountPaths.push_back(path); } +namespace { + +bool looksAbsolute(const std::string &path) { + if (path.empty()) return false; + if (path[0] == '/' || path[0] == '\\') return true; +#ifdef EVENGINE_WINDOWS + if (path.size() >= 2 && path[1] == ':') return true; +#endif + return false; +} + +std::string joinPath(const std::string &a, const std::string &b) { + if (a.empty()) return b; + if (b.empty()) return a; + char last = a.back(); + if (last == '/' || last == '\\') return a + b; + return a + "/" + b; +} + +std::string parentDir(const std::string &path) { + auto pos = path.find_last_of("/\\"); + if (pos == std::string::npos) return "."; + if (pos == 0) return "/"; + return path.substr(0, pos); +} + +std::string baseName(const std::string &path) { + auto pos = path.find_last_of("/\\"); + if (pos == std::string::npos) return path; + return path.substr(pos + 1); +} + +} // namespace + +FileWatch &Filesystem::watchers() { + if (!fileWatch_) fileWatch_ = std::make_unique(); + return *fileWatch_; +} + +bool Filesystem::resolveWatchTarget(const std::string &path, std::string &realDir, + std::string &filterName, std::string &reportPath) { + reportPath = path; + filterName.clear(); + realDir.clear(); + if (path.empty()) return false; + + if (looksAbsolute(path)) { + if (isRealDirectory(path)) { + realDir = path; + return true; + } + realDir = parentDir(path); + filterName = baseName(path); + return isRealDirectory(realDir); + } + + Info info{}; + if (getInfo(path, info)) { + std::string root; + try { + root = getRealDirectory(path); + } catch (...) { + return false; + } + std::string full = joinPath(root, path); + if (info.type == "directory") { + realDir = full; + filterName.clear(); + return isRealDirectory(realDir); + } + realDir = parentDir(full); + filterName = baseName(full); + return isRealDirectory(realDir); + } + + std::string parent = parentDir(path); + if (parent == ".") parent.clear(); + if (!parent.empty()) { + Info pinfo{}; + if (getInfo(parent, pinfo) && pinfo.type == "directory") { + std::string root; + try { + root = getRealDirectory(parent); + } catch (...) { + return false; + } + realDir = joinPath(root, parent); + filterName = baseName(path); + return isRealDirectory(realDir); + } + } + + std::string save = getSaveDirectory(); + if (!save.empty() && isRealDirectory(save)) { + realDir = save; + filterName = baseName(path); + return true; + } + return false; +} + +bool Filesystem::watch(std::string path) { + std::string realDir, filter, report; + if (!resolveWatchTarget(path, realDir, filter, report)) return false; + return watchers().add(realDir, filter, report, 1); +} + +bool Filesystem::unwatch(std::string path) { return watchers().remove(path); } + +void Filesystem::unwatchAll() { + if (fileWatch_) fileWatch_->clear(); +} + +int Filesystem::getWatchCount() const { + if (!fileWatch_) return 0; + return fileWatch_->count(); +} + +std::string Filesystem::pollWatch() { + if (!fileWatch_) return ""; + FileWatch::Event ev; + if (!fileWatch_->poll(ev)) { + lastWatchPath_.clear(); + lastWatchRealPath_.clear(); + return ""; + } + lastWatchPath_ = ev.path; + lastWatchRealPath_ = ev.realPath; + return ev.kind; +} + +std::string Filesystem::getLastWatchPath() const { return lastWatchPath_; } +std::string Filesystem::getLastWatchRealPath() const { return lastWatchRealPath_; } + } // namespace physfs } // namespace filesystem } // namespace eve diff --git a/src/modules/filesystem/physfs/Filesystem.h b/src/modules/filesystem/physfs/Filesystem.h index 8e3e43c7..7f1d080d 100644 --- a/src/modules/filesystem/physfs/Filesystem.h +++ b/src/modules/filesystem/physfs/Filesystem.h @@ -3,8 +3,11 @@ #include #include #include +#include +#include #include "filesystem/Filesystem.h" +#include "filesystem/FileWatch.h" namespace eve { namespace filesystem { @@ -13,7 +16,7 @@ namespace physfs { class Filesystem final : public eve::filesystem::Filesystem { public: Filesystem(); - virtual ~Filesystem(); + ~Filesystem() override; void init(const char* arg0) override; @@ -65,7 +68,19 @@ public: void allowMountingForPath(const std::string &path) override; + bool watch(std::string path) override; + bool unwatch(std::string path) override; + void unwatchAll() override; + int getWatchCount() const override; + std::string pollWatch() override; + std::string getLastWatchPath() const override; + std::string getLastWatchRealPath() const override; + private: + bool resolveWatchTarget(const std::string &path, std::string &realDir, std::string &filterName, + std::string &reportPath); + FileWatch &watchers(); + // Contains the current working directory (UTF8). std::string cwd; @@ -97,9 +112,12 @@ private: std::map mountedData; + std::unique_ptr fileWatch_; + std::string lastWatchPath_; + std::string lastWatchRealPath_; + }; // Filesystem } // namespace physfs } // namespace filesystem } // namespace eve - diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index efeb189e..25011eb7 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -124,6 +124,17 @@ public: */ virtual void requestSurfaceRecreate() {} + /** + * Optional overlay drawn inside the swapchain render pass (before end). + * Used by declarative UI (ImGui). `commandBuffer` is a VkCommandBuffer as void*. + */ + using PresentOverlayFn = void (*)(void *userdata, void *commandBuffer); + void setPresentOverlay(PresentOverlayFn fn, void *userdata) { + presentOverlayFn_ = fn; + presentOverlayUser_ = userdata; + } + PresentOverlayFn getPresentOverlay() const { return presentOverlayFn_; } + void *getPresentOverlayUser() const { return presentOverlayUser_; } // void setFont(Font *font); // Font *getFont(); @@ -261,6 +272,8 @@ protected: bool frameHad3D = false; bool screenReadbackEnabled = false; bool graphicsActive = true; + PresentOverlayFn presentOverlayFn_ = nullptr; + void *presentOverlayUser_ = nullptr; }; -} // namespace graphics +} // namespace eve::graphics diff --git a/src/modules/graphics/vulkan/Graphics.cpp b/src/modules/graphics/vulkan/Graphics.cpp index 9bf35248..6a223830 100644 --- a/src/modules/graphics/vulkan/Graphics.cpp +++ b/src/modules/graphics/vulkan/Graphics.cpp @@ -52,7 +52,7 @@ Graphics::~Graphics() { if (mesh3dPipeline) device->destroyPipeline(mesh3dPipeline); if (mesh3dPipelineLayout) device->destroyPipelineLayout(mesh3dPipelineLayout); mesh3dSetLayoutUnique.reset(); - mesh3dUbo = vkb::GenericBuffer{}; + mesh3dUboSlots.clear(); if (offscreenSolidPipeline) device->destroyPipeline(offscreenSolidPipeline); if (offscreenTexPipeline) device->destroyPipeline(offscreenTexPipeline); if (offscreenRenderPass) device->destroyRenderPass(offscreenRenderPass); @@ -64,7 +64,15 @@ Graphics::~Graphics() { } void Graphics::initWithWindow(void *nativeWindow) { - if (initialized) return; + if (initialized) { + // Window module may destroy/recreate the SDL window (tests, setWindowSettings). + // The Vulkan surface is tied to the native window — rebuild it when the + // handle changes, otherwise acquire/present use a destroyed surface. + if (sdlWindow == nativeWindow) return; + sdlWindow = nativeWindow; + recreateSurfaceForResume(); + return; + } sdlWindow = nativeWindow; auto *window = static_cast(nativeWindow); ASSERT(window != nullptr); @@ -134,6 +142,41 @@ void Graphics::destroySwapchainResources() { depthImage = vkb::DepthStencilImage{}; } +bool Graphics::rebuildSwapchainIfNeeded() { + const bool wantRecreate = + surfaceNeedsRecreate.load() || swapchainDirty || presentModel.needs_recreate; + if (!wantRecreate) return true; + if (!isRenderSurfaceStable()) return false; + + if (surfaceNeedsRecreate.exchange(false)) + recreateSurfaceForResume(); + if (presentModel.needs_recreate) { + presentModel.needs_recreate = false; + swapchainDirty = true; + } + if (swapchainDirty) { + device->waitIdle(); + createSwapchainAndPipeline(); + } + return true; +} + +bool Graphics::beginPresentCommandBuffer() { + for (int attempt = 0; attempt < 3; ++attempt) { + if (!rebuildSwapchainIfNeeded()) return false; + presentModel.begin(); + if (presentModel.has_acquired_image) return true; + // Acquire failed without beginning the CB (see Present::begin). Rebuild once. + if (presentModel.needs_recreate) { + presentModel.needs_recreate = false; + swapchainDirty = true; + continue; + } + return false; + } + return false; +} + bool Graphics::isRenderSurfaceReady() const { #if defined(EVENGINE_ANDROID) || defined(EVENGINE_IOS) auto *window = static_cast(sdlWindow); @@ -285,11 +328,11 @@ void Graphics::createTexturedPipeline() { texSetLayout = *texSetLayoutUnique; vk::DescriptorPoolSize poolSizes[] = { - {vk::DescriptorType::eCombinedImageSampler, 64}, - {vk::DescriptorType::eUniformBuffer, 16}, + {vk::DescriptorType::eCombinedImageSampler, 128}, + {vk::DescriptorType::eUniformBuffer, 64}, }; vk::DescriptorPoolCreateInfo poolInfo{}; - poolInfo.maxSets = 80; + poolInfo.maxSets = 160; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; descriptorPool = device->createDescriptorPool(poolInfo); @@ -404,17 +447,9 @@ void Graphics::createMesh3DPipeline() { plInfo.pSetLayouts = &mesh3dSetLayout; mesh3dPipelineLayout = device->createPipelineLayout(plInfo); - mesh3dUbo.allocate(device, vk::BufferUsageFlagBits::eUniformBuffer, sizeof(Mesh3DUBO), - vk::MemoryPropertyFlagBits::eHostVisible | - vk::MemoryPropertyFlagBits::eHostCoherent); - Mesh3DUBO initial{}; - mesh3dUbo.updateLocal(initial); - - vk::DescriptorSetAllocateInfo alloc{}; - alloc.descriptorPool = descriptorPool; - alloc.descriptorSetCount = 1; - alloc.pSetLayouts = &mesh3dSetLayout; - mesh3dDescriptorSet = device->allocateDescriptorSets(alloc).front(); + // UBO + descriptor sets are allocated lazily per draw (see mesh3dSetFor). + mesh3dUboSlots.clear(); + mesh3dDrawIndex = 0; std::vector vert(mesh3d_vert_spv, mesh3d_vert_spv + mesh3d_vert_spv_count); std::vector frag(mesh3d_frag_spv, mesh3d_frag_spv + mesh3d_frag_spv_count); @@ -943,31 +978,6 @@ void Graphics::flushToOffscreen(OffscreenCanvas *canvas) { } void Graphics::flushToSwapchain() { - // Any of these mean the swapchain must be rebuilt before we can present. - // presentModel.needs_recreate is set by vkbuilder when acquire/present - // reported out-of-date/suboptimal (instead of rebuilding inline). - const bool wantRecreate = - surfaceNeedsRecreate.load() || swapchainDirty || presentModel.needs_recreate; - if (wantRecreate) { - // Only (re)build once the native surface has settled. On Android the - // window is briefly reported at a transient (portrait) size and marked - // "not ready" during resume/rotation; building a swapchain then crashes - // the GPU driver in vkCreateImageView. Skip the frame until it's stable. - if (!isRenderSurfaceStable()) - return; - - if (surfaceNeedsRecreate.exchange(false)) - recreateSurfaceForResume(); - if (presentModel.needs_recreate) { - presentModel.needs_recreate = false; - swapchainDirty = true; - } - if (swapchainDirty) { - device->waitIdle(); - createSwapchainAndPipeline(); - } - } - Batcher solid = solidBatch; auto textured = std::move(texturedBatches); solidBatch.clear(); @@ -975,7 +985,10 @@ void Graphics::flushToSwapchain() { const bool continue3D = swapchainPassOpen; if (!continue3D) { - presentModel.begin(); + if (!beginPresentCommandBuffer()) { + hasPendingClear = false; + return; + } std::array clears{}; clears[0].color = vk::ClearColorValue( std::array{clearColor.r, clearColor.g, clearColor.b, clearColor.a}); @@ -1031,6 +1044,14 @@ void Graphics::flushToSwapchain() { } } + // Invalidate prior readback so a failed present cannot reuse a stale frame. + if (screenReadbackEnabled) hasPresentedFrame = false; + + if (presentOverlayFn_) { + VkCommandBuffer raw = static_cast(cb); + presentOverlayFn_(presentOverlayUser_, raw); + } + presentModel.endRenderPass(); presentModel.end(); presentModel.drawFrame(); @@ -1045,12 +1066,9 @@ void Graphics::begin3DFrame() { if (!initialized) throw Exception("begin3DFrame: graphics not initialized"); if (isCanvasActive()) throw Exception("begin3DFrame: cannot start 3D while a Canvas is active"); if (swapchainPassOpen) throw Exception("begin3DFrame: swapchain pass already open"); - if (swapchainDirty) { - device->waitIdle(); - createSwapchainAndPipeline(); - } + if (!beginPresentCommandBuffer()) + throw Exception("begin3DFrame: failed to acquire swapchain image"); - presentModel.begin(); std::array clears{}; clears[0].color = vk::ClearColorValue( std::array{clearColor.r, clearColor.g, clearColor.b, clearColor.a}); @@ -1063,6 +1081,7 @@ void Graphics::begin3DFrame() { cb.setViewport(0, 1, &vp); cb.setScissor(0, 1, &scissor); + mesh3dDrawIndex = 0; swapchainPassOpen = true; frameHad3D = true; hasPendingClear = false; @@ -1075,6 +1094,38 @@ void Graphics::setMesh3DLight(const glm::vec3 &dir, const glm::vec3 &color) { mesh3dFrameUbo.lightColor = glm::vec4(color, 1.f); } +vk::DescriptorSet Graphics::mesh3dSetFor(GpuTexture *gpuTex, size_t uboSlot) { + ASSERT(gpuTex != nullptr); + while (mesh3dUboSlots.size() <= uboSlot) { + mesh3dUboSlots.emplace_back(); + mesh3dUboSlots.back().ubo.allocate(device, vk::BufferUsageFlagBits::eUniformBuffer, + sizeof(Mesh3DUBO), + vk::MemoryPropertyFlagBits::eHostVisible | + vk::MemoryPropertyFlagBits::eHostCoherent); + } + auto &slot = mesh3dUboSlots[uboSlot]; + auto it = slot.sets.find(gpuTex); + if (it != slot.sets.end()) return it->second; + + vk::DescriptorSetAllocateInfo alloc{}; + alloc.descriptorPool = descriptorPool; + alloc.descriptorSetCount = 1; + alloc.pSetLayouts = &mesh3dSetLayout; + vk::DescriptorSet set = device->allocateDescriptorSets(alloc).front(); + + // Fresh set — never bound to a command buffer yet, so Update is safe. + vkb::DescriptorSetUpdater updater; + updater.beginDescriptorSet(set) + .beginBuffers(0, 0, vk::DescriptorType::eUniformBuffer) + .buffer(slot.ubo.buffer, 0, sizeof(Mesh3DUBO)) + .beginImages(1, 0, vk::DescriptorType::eCombinedImageSampler) + .image(gpuTex->sampler, gpuTex->image.imageView(), vk::ImageLayout::eShaderReadOnlyOptimal) + .update(device.instance); + + slot.sets.emplace(gpuTex, set); + return set; +} + void Graphics::setMesh3DViewProj(const glm::mat4 &viewProj) { mesh3dFrameUbo.mvp = viewProj; } @@ -1150,20 +1201,15 @@ void Graphics::drawMesh(Mesh *mesh, const glm::mat4 &model, Texture *texture, co ubo.model = model; ubo.mvp = mesh3dFrameUbo.mvp * model; ubo.tint = glm::vec4(tint.r, tint.g, tint.b, tint.a); - mesh3dUbo.updateLocal(ubo); - vkb::DescriptorSetUpdater updater; - updater.beginDescriptorSet(mesh3dDescriptorSet) - .beginBuffers(0, 0, vk::DescriptorType::eUniformBuffer) - .buffer(mesh3dUbo.buffer, 0, sizeof(Mesh3DUBO)) - .beginImages(1, 0, vk::DescriptorType::eCombinedImageSampler) - .image(gpuTex->sampler, gpuTex->image.imageView(), vk::ImageLayout::eShaderReadOnlyOptimal) - .update(device.instance); + const size_t slot = mesh3dDrawIndex++; + vk::DescriptorSet set = mesh3dSetFor(gpuTex, slot); + mesh3dUboSlots[slot].ubo.updateLocal(ubo); auto &cb = presentModel.getCurrentCommandBuffer(); cb.bindPipeline(vk::PipelineBindPoint::eGraphics, mesh3dPipeline); - cb.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, mesh3dPipelineLayout, 0, 1, - &mesh3dDescriptorSet, 0, nullptr); + cb.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, mesh3dPipelineLayout, 0, 1, &set, 0, + nullptr); vk::DeviceSize offset = 0; cb.bindVertexBuffers(0, 1, gpuMesh->vertices, &offset); cb.bindIndexBuffer(gpuMesh->indices.buffer, 0, vk::IndexType::eUint32); diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 034f9471..dc174baa 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -6,6 +6,7 @@ #include "vkbuilder.hpp" #include #include +#include #include #include #include @@ -134,6 +135,11 @@ public: vk::DescriptorSetLayout getTexSetLayout() const { return texSetLayout; } vk::DescriptorPool getDescriptorPool() const { return descriptorPool; } vk::RenderPass getOffscreenRenderPass() const { return offscreenRenderPass; } + vk::RenderPass getSwapchainRenderPass() const { return renderpass; } + vkb::Instance &getInstance() { return inst; } + vkb::Swapchain &getSwapchain() { return swapchain; } + void *getSdlWindow() const { return sdlWindow; } + uint32_t getSwapchainImageCount() const { return swapchain.image_count; } friend class OffscreenCanvas; @@ -148,6 +154,11 @@ private: void flushToOffscreen(OffscreenCanvas *canvas); void captureSwapchainImage(uint32_t imageIndex); void ensurePresentCaptureHook(); + vk::DescriptorSet mesh3dSetFor(GpuTexture *gpuTex, size_t uboSlot); + /** Rebuild surface/swapchain when dirty. Returns false if surface not ready. */ + bool rebuildSwapchainIfNeeded(); + /** acquire + begin command buffer; recreates swapchain and retries on failure. */ + bool beginPresentCommandBuffer(); bool initialized = false; bool hasPresentedFrame = false; @@ -195,8 +206,15 @@ private: vk::UniqueDescriptorSetLayout mesh3dSetLayoutUnique; vk::PipelineLayout mesh3dPipelineLayout; vk::Pipeline mesh3dPipeline; - vkb::GenericBuffer mesh3dUbo; - vk::DescriptorSet mesh3dDescriptorSet{}; + // One UBO (+ per-texture descriptor sets) per draw in the current 3D frame. + // Avoids vkUpdateDescriptorSets on a set already bound in a recording / + // executable command buffer (which invalidates the CB). + struct Mesh3dUboSlot { + vkb::GenericBuffer ubo; + std::unordered_map sets; + }; + std::vector mesh3dUboSlots; + size_t mesh3dDrawIndex = 0; Texture *whiteTexture = nullptr; vkb::Present presentModel; diff --git a/src/modules/particles/ParticleConfig.cpp b/src/modules/particles/ParticleConfig.cpp new file mode 100644 index 00000000..8dd7cfbc --- /dev/null +++ b/src/modules/particles/ParticleConfig.cpp @@ -0,0 +1,237 @@ +#include "particles/ParticleConfig.h" + +#include "common/Module.h" +#include "data/DataModule.h" +#include "data/JsonDocument.h" +#include "filesystem/Filesystem.h" +#include "filesystem/FileData.h" +#include "graphics/Graphics.h" + +#include +#include +#include + +#include + +namespace eve::particles { +namespace { + +float asFloat(const Poco::Dynamic::Var &v, float fallback) { + try { + if (v.isEmpty()) return fallback; + return static_cast(v.convert()); + } catch (...) { + return fallback; + } +} + +bool asBool(const Poco::Dynamic::Var &v, bool fallback) { + try { + if (v.isEmpty()) return fallback; + return v.convert(); + } catch (...) { + return fallback; + } +} + +std::string asString(const Poco::Dynamic::Var &v) { + try { + if (v.isEmpty()) return {}; + return v.convert(); + } catch (...) { + return {}; + } +} + +bool readVec2(Poco::JSON::Object::Ptr o, const char *key, float &a, float &b) { + if (!o || !o->has(key)) return false; + Poco::JSON::Array::Ptr arr; + try { + arr = o->getArray(key); + } catch (...) { + return false; + } + if (!arr || arr->size() < 2) return false; + a = asFloat(arr->get(0), a); + b = asFloat(arr->get(1), b); + return true; +} + +bool readVec4(Poco::JSON::Object::Ptr o, const char *key, float &a, float &b, float &c, float &d) { + if (!o || !o->has(key)) return false; + Poco::JSON::Array::Ptr arr; + try { + arr = o->getArray(key); + } catch (...) { + return false; + } + if (!arr || arr->size() < 4) return false; + a = asFloat(arr->get(0), a); + b = asFloat(arr->get(1), b); + c = asFloat(arr->get(2), c); + d = asFloat(arr->get(3), d); + return true; +} + +void tryLoadTexture(ParticleEmitter *emitter, const std::string &path) { + if (path.empty()) return; + auto *gfx = eve::ModuleManager::getInstance("Graphics"); + if (!gfx) return; + try { + graphics::Texture *tex = gfx->newTextureFromFile(path); + emitter->setTexture(tex); + } catch (...) { + // Leave existing / null texture; config still applied. + } +} + +int64_t fileModtime(const std::string &path) { + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + eve::filesystem::Filesystem::Info info{}; + if (!fs->getInfo(path, info)) return -1; + return info.modtime; +} + +} // namespace + +bool applyConfigDocument(ParticleEmitter *emitter, data::JsonDocument *doc) { + if (!emitter || !doc || !doc->isObject()) return false; + auto obj = doc->object(); + if (!obj) return false; + + // Optional named preset first; later keys override. + if (obj->has("preset")) { + std::string preset = asString(obj->get("preset")); + if (!preset.empty()) emitter->applyPreset(preset); + } + + if (obj->has("x") || obj->has("y")) { + float x = emitter->getX(); + float y = emitter->getY(); + if (obj->has("x")) x = asFloat(obj->get("x"), x); + if (obj->has("y")) y = asFloat(obj->get("y"), y); + emitter->setPosition(x, y); + } + + if (obj->has("emissionRate")) + emitter->setEmissionRate(asFloat(obj->get("emissionRate"), emitter->getEmissionRate())); + + float lifeMin = emitter->getParticleLifetimeMin(); + float lifeMax = emitter->getParticleLifetimeMax(); + if (readVec2(obj, "particleLifetime", lifeMin, lifeMax)) + emitter->setParticleLifetime(lifeMin, lifeMax); + else { + if (obj->has("lifeMin")) lifeMin = asFloat(obj->get("lifeMin"), lifeMin); + if (obj->has("lifeMax")) lifeMax = asFloat(obj->get("lifeMax"), lifeMax); + if (obj->has("lifeMin") || obj->has("lifeMax")) + emitter->setParticleLifetime(lifeMin, lifeMax); + } + + if (obj->has("emitterLife")) + emitter->setEmitterLifetime(asFloat(obj->get("emitterLife"), emitter->getEmitterLifetime())); + + if (obj->has("direction")) + emitter->setDirection(asFloat(obj->get("direction"), emitter->getDirection())); + if (obj->has("spread")) + emitter->setSpread(asFloat(obj->get("spread"), emitter->getSpread())); + + float speedMin = 0.f, speedMax = 0.f; + if (readVec2(obj, "speed", speedMin, speedMax)) + emitter->setSpeed(speedMin, speedMax); + + float ax0 = 0, ay0 = 0, ax1 = 0, ay1 = 0; + if (readVec4(obj, "linearAcceleration", ax0, ay0, ax1, ay1)) + emitter->setLinearAcceleration(ax0, ay0, ax1, ay1); + + float pw = emitter->getParticleWidth(), ph = emitter->getParticleHeight(); + if (readVec2(obj, "particleSize", pw, ph)) + emitter->setParticleSize(pw, ph); + + float ss = 1.f, se = 1.f; + if (readVec2(obj, "sizes", ss, se)) + emitter->setSizes(ss, se); + + float r = 1, g = 1, b = 1, a = 1; + if (readVec4(obj, "colorStart", r, g, b, a)) + emitter->setColorStart(r, g, b, a); + if (readVec4(obj, "colorEnd", r, g, b, a)) + emitter->setColorEnd(r, g, b, a); + + if (obj->has("layer")) + emitter->setLayer(static_cast(asFloat(obj->get("layer"), float(emitter->getLayer())))); + if (obj->has("visible")) + emitter->setVisible(asBool(obj->get("visible"), emitter->isVisible())); + + if (obj->has("texture")) { + std::string texPath = asString(obj->get("texture")); + emitter->resource()->texturePath = texPath; + tryLoadTexture(emitter, texPath); + } + + if (obj->has("autoReload")) + emitter->resource()->autoReload = asBool(obj->get("autoReload"), true); + + if (obj->has("autoStart") && asBool(obj->get("autoStart"), false)) + emitter->start(); + + return true; +} + +bool applyConfigText(ParticleEmitter *emitter, const std::string &json, std::string *error) { + auto *dm = eve::data::DataModule::create(); + std::string err; + std::unique_ptr doc(dm->decodeJson(json, &err)); + if (!doc) { + if (error) *error = err.empty() ? "invalid json" : err; + return false; + } + if (!applyConfigDocument(emitter, doc.get())) { + if (error) *error = "config root must be object"; + return false; + } + return true; +} + +bool loadConfigFile(ParticleEmitter *emitter, const std::string &path, std::string *error) { + if (!emitter || path.empty()) { + if (error) *error = "empty path"; + return false; + } + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + + std::unique_ptr data; + try { + data.reset(fs->read(path)); + } catch (...) { + if (error) *error = "read failed: " + path; + return false; + } + if (!data || data->getSize() == 0) { + if (error) *error = "empty file: " + path; + return false; + } + + std::string text(static_cast(data->getData()), data->getSize()); + if (!applyConfigText(emitter, text, error)) return false; + + auto res = emitter->resource(); + res->path = path; + res->modtime = fileModtime(path); + // Prefer OS watch; mtime remains a fallback in ParticleConfigSystem. + fs->watch(path); + return true; +} + +bool reloadConfigFile(ParticleEmitter *emitter, std::string *error) { + if (!emitter) return false; + const std::string &path = emitter->resource()->path; + if (path.empty()) { + if (error) *error = "no config path"; + return false; + } + return loadConfigFile(emitter, path, error); +} + +} // namespace eve::particles diff --git a/src/modules/particles/ParticleConfig.h b/src/modules/particles/ParticleConfig.h new file mode 100644 index 00000000..3c27f7e6 --- /dev/null +++ b/src/modules/particles/ParticleConfig.h @@ -0,0 +1,32 @@ +#pragma once + +#include "particles/ParticleEmitter.h" + +#include + +namespace eve::data { +class JsonDocument; +} + +namespace eve::particles { + +/** + * Apply particle JSON config onto an emitter (Config + Draw + Resource.texturePath). + * Does not change buffer size or clear live particles. + * Returns false on invalid / missing object root. + */ +bool applyConfigDocument(ParticleEmitter *emitter, data::JsonDocument *doc); + +/** Parse JSON text and apply. */ +bool applyConfigText(ParticleEmitter *emitter, const std::string &json, std::string *error = nullptr); + +/** + * Read path via Filesystem, apply, and bind Resource.path + modtime for hot reload. + * Returns false if file missing / invalid JSON. + */ +bool loadConfigFile(ParticleEmitter *emitter, const std::string &path, std::string *error = nullptr); + +/** Re-read Resource.path if set; updates modtime. */ +bool reloadConfigFile(ParticleEmitter *emitter, std::string *error = nullptr); + +} // namespace eve::particles diff --git a/src/modules/particles/ParticleEmitter.cpp b/src/modules/particles/ParticleEmitter.cpp new file mode 100644 index 00000000..bd3c6609 --- /dev/null +++ b/src/modules/particles/ParticleEmitter.cpp @@ -0,0 +1,272 @@ +#include "particles/ParticleEmitter.h" +#include "particles/ParticleConfig.h" + +#include +#include + +namespace eve::particles { + +namespace { +constexpr float kPi = 3.14159265358979323846f; + +float randRange(std::mt19937 &rng, float a, float b) { + if (a == b) return a; + std::uniform_real_distribution dist(a, b); + return dist(rng); +} + +} // namespace + +void spawnParticle(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim) { + if (sim.alive >= int(sim.particles.size())) return; + + Particle &p = sim.particles[size_t(sim.alive++)]; + p.x = cfg.x; + p.y = cfg.y; + p.lifetime = randRange(sim.rng, cfg.lifeMin, cfg.lifeMax); + if (p.lifetime <= 0.f) p.lifetime = 1e-4f; + p.life = p.lifetime; + p.ax = randRange(sim.rng, cfg.accelXMin, cfg.accelXMax); + p.ay = randRange(sim.rng, cfg.accelYMin, cfg.accelYMax); + + const float half = cfg.spread * 0.5f; + const float angle = cfg.direction + randRange(sim.rng, -half, half); + const float speed = randRange(sim.rng, cfg.speedMin, cfg.speedMax); + p.vx = std::cos(angle) * speed; + p.vy = std::sin(angle) * speed; + p.size = cfg.sizeStart; +} + +void stepEmitterSim(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim, float dt) { + if (sim.paused || dt <= 0.f) return; + + int write = 0; + for (int i = 0; i < sim.alive; ++i) { + Particle &p = sim.particles[size_t(i)]; + p.life -= dt; + if (p.life <= 0.f) continue; + + p.vx += p.ax * dt; + p.vy += p.ay * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + + if (write != i) sim.particles[size_t(write)] = p; + ++write; + } + sim.alive = write; + + if (!sim.active) return; + + if (cfg.emitterLife >= 0.f) { + sim.emitterAge += dt; + if (sim.emitterAge >= cfg.emitterLife) { + sim.active = false; + sim.emitAccum = 0.f; + return; + } + } + + if (cfg.emissionRate <= 0.f) return; + sim.emitAccum += cfg.emissionRate * dt; + while (sim.emitAccum >= 1.f) { + spawnParticle(cfg, sim); + sim.emitAccum -= 1.f; + if (sim.alive >= int(sim.particles.size())) { + sim.emitAccum = 0.f; + break; + } + } +} + +ParticleEmitter *ParticleEmitter::createEmitter(int bufferSize) { + ParticleEmitter *e = ParticleEmitter::create(); + e->config()->entity = e; + const int n = bufferSize > 0 ? bufferSize : 1; + e->sim()->particles.resize(size_t(n)); + std::random_device rd; + e->sim()->rng.seed(rd()); + return e; +} + +void ParticleEmitter::setPosition(float x, float y) { + config()->x = x; + config()->y = y; +} + +void ParticleEmitter::moveTo(float x, float y) { setPosition(x, y); } + +float ParticleEmitter::getX() { return config()->x; } +float ParticleEmitter::getY() { return config()->y; } + +void ParticleEmitter::setEmissionRate(float rate) { + config()->emissionRate = rate < 0.f ? 0.f : rate; +} + +float ParticleEmitter::getEmissionRate() { return config()->emissionRate; } + +void ParticleEmitter::setParticleLifetime(float minLife, float maxLife) { + auto c = config(); + c->lifeMin = minLife < 0.f ? 0.f : minLife; + c->lifeMax = maxLife < c->lifeMin ? c->lifeMin : maxLife; +} + +float ParticleEmitter::getParticleLifetimeMin() { return config()->lifeMin; } +float ParticleEmitter::getParticleLifetimeMax() { return config()->lifeMax; } + +void ParticleEmitter::setEmitterLifetime(float seconds) { config()->emitterLife = seconds; } +float ParticleEmitter::getEmitterLifetime() { return config()->emitterLife; } + +void ParticleEmitter::setDirection(float radians) { config()->direction = radians; } +float ParticleEmitter::getDirection() { return config()->direction; } + +void ParticleEmitter::setSpread(float radians) { + config()->spread = radians < 0.f ? 0.f : radians; +} +float ParticleEmitter::getSpread() { return config()->spread; } + +void ParticleEmitter::setSpeed(float minSpeed, float maxSpeed) { + auto c = config(); + c->speedMin = minSpeed; + c->speedMax = maxSpeed < minSpeed ? minSpeed : maxSpeed; +} + +void ParticleEmitter::setLinearAcceleration(float xmin, float ymin, float xmax, float ymax) { + auto c = config(); + c->accelXMin = xmin; + c->accelYMin = ymin; + c->accelXMax = xmax < xmin ? xmin : xmax; + c->accelYMax = ymax < ymin ? ymin : ymax; +} + +void ParticleEmitter::setParticleSize(float width, float height) { + auto c = config(); + c->particleW = width > 0.f ? width : 1.f; + c->particleH = height > 0.f ? height : 1.f; +} + +float ParticleEmitter::getParticleWidth() { return config()->particleW; } +float ParticleEmitter::getParticleHeight() { return config()->particleH; } + +void ParticleEmitter::setSizes(float startScale, float endScale) { + config()->sizeStart = startScale; + config()->sizeEnd = endScale; +} + +void ParticleEmitter::setColorStart(float r, float g, float b, float a) { + config()->colorStart = Color(r, g, b, a); +} + +void ParticleEmitter::setColorEnd(float r, float g, float b, float a) { + config()->colorEnd = Color(r, g, b, a); +} + +void ParticleEmitter::setTexture(graphics::Texture *texture) { draw()->texture = texture; } +graphics::Texture *ParticleEmitter::getTexture() { return draw()->texture; } + +void ParticleEmitter::setLayer(int layer) { draw()->layer = layer; } +int ParticleEmitter::getLayer() { return draw()->layer; } + +void ParticleEmitter::setVisible(bool visible) { draw()->visible = visible; } +bool ParticleEmitter::isVisible() { return draw()->visible; } + +void ParticleEmitter::start() { + auto s = sim(); + s->active = true; + s->paused = false; + s->emitterAge = 0.f; +} + +void ParticleEmitter::stop() { + auto s = sim(); + s->active = false; + s->paused = false; + s->emitAccum = 0.f; + s->emitterAge = 0.f; +} + +void ParticleEmitter::pause() { + auto s = sim(); + if (s->active) s->paused = true; +} + +void ParticleEmitter::reset() { + auto s = sim(); + s->alive = 0; + s->emitAccum = 0.f; + s->emitterAge = 0.f; + for (auto &p : s->particles) p.life = 0.f; +} + +void ParticleEmitter::emit(int count) { + if (count <= 0) return; + auto c = config(); + auto s = sim(); + for (int i = 0; i < count; ++i) spawnParticle(*c, *s); +} + +bool ParticleEmitter::isActive() { + auto s = sim(); + return s->active && !s->paused; +} +bool ParticleEmitter::isPaused() { return sim()->paused; } +bool ParticleEmitter::isStopped() { return !sim()->active; } + +int ParticleEmitter::getCount() { return sim()->alive; } +int ParticleEmitter::getBufferSize() { return int(sim()->particles.size()); } + +void ParticleEmitter::applyPreset(const std::string &name) { + if (name == "spark") { + setEmissionRate(80.f); + setParticleLifetime(0.2f, 0.6f); + setEmitterLifetime(-1.f); + setDirection(-kPi * 0.5f); + setSpread(kPi * 0.6f); + setSpeed(60.f, 180.f); + setLinearAcceleration(-20.f, 40.f, 20.f, 120.f); + setParticleSize(4.f, 4.f); + setSizes(1.f, 0.2f); + setColorStart(1.f, 0.9f, 0.3f, 1.f); + setColorEnd(1.f, 0.2f, 0.f, 0.f); + } else if (name == "smoke") { + setEmissionRate(25.f); + setParticleLifetime(1.5f, 3.f); + setEmitterLifetime(-1.f); + setDirection(-kPi * 0.5f); + setSpread(0.4f); + setSpeed(10.f, 40.f); + setLinearAcceleration(-5.f, -30.f, 5.f, -10.f); + setParticleSize(16.f, 16.f); + setSizes(0.5f, 2.f); + setColorStart(0.5f, 0.5f, 0.5f, 0.5f); + setColorEnd(0.3f, 0.3f, 0.3f, 0.f); + } else if (name == "fire") { + setEmissionRate(60.f); + setParticleLifetime(0.4f, 1.0f); + setEmitterLifetime(-1.f); + setDirection(-kPi * 0.5f); + setSpread(0.5f); + setSpeed(20.f, 80.f); + setLinearAcceleration(-15.f, -80.f, 15.f, -20.f); + setParticleSize(10.f, 10.f); + setSizes(1.2f, 0.3f); + setColorStart(1.f, 0.7f, 0.1f, 1.f); + setColorEnd(1.f, 0.1f, 0.f, 0.f); + } +} + +bool ParticleEmitter::applyConfig(const std::string &json) { + return applyConfigText(this, json, nullptr); +} + +bool ParticleEmitter::loadConfig(const std::string &path) { + return loadConfigFile(this, path, nullptr); +} + +bool ParticleEmitter::reloadConfig() { return reloadConfigFile(this, nullptr); } + +void ParticleEmitter::setAutoReload(bool enable) { resource()->autoReload = enable; } +bool ParticleEmitter::getAutoReload() { return resource()->autoReload; } +std::string ParticleEmitter::getConfigPath() { return resource()->path; } + +} // namespace eve::particles diff --git a/src/modules/particles/ParticleEmitter.h b/src/modules/particles/ParticleEmitter.h new file mode 100644 index 00000000..f20283bb --- /dev/null +++ b/src/modules/particles/ParticleEmitter.h @@ -0,0 +1,167 @@ +#pragma once + +#include "common/ECS.h" +#include "graphics/Canvas.h" +#include "graphics/Texture.h" + +#include +#include +#include +#include + +namespace eve::graphics { +class Graphics; +} + +namespace eve::particles { + +/** Single live particle (CPU simulation). */ +struct Particle { + float x = 0.f; + float y = 0.f; + float vx = 0.f; + float vy = 0.f; + float ax = 0.f; + float ay = 0.f; + float life = 0.f; + float lifetime = 1.f; + float size = 1.f; +}; + +/** + * ECS emitter entity. Script configures components; ParticleSimSystem / + * ParticleRenderSystem drive per-frame update & draw. + */ +class ParticleEmitter : public ecs::Entity { +public: + ENTITY(ParticleEmitter, ecs::Entity) + + void release() override {} + + struct Config { + float x = 0.f; + float y = 0.f; + float emissionRate = 0.f; + float lifeMin = 1.f; + float lifeMax = 1.f; + float emitterLife = -1.f; // -1 = forever + float direction = 0.f; + float spread = 0.f; + float speedMin = 0.f; + float speedMax = 0.f; + float accelXMin = 0.f, accelYMin = 0.f; + float accelXMax = 0.f, accelYMax = 0.f; + float particleW = 8.f; + float particleH = 8.f; + float sizeStart = 1.f; + float sizeEnd = 1.f; + Color colorStart{1.f, 1.f, 1.f, 1.f}; + Color colorEnd{1.f, 1.f, 1.f, 0.f}; + ParticleEmitter *entity = nullptr; + }; + + struct Sim { + std::vector particles; + int alive = 0; + float emitAccum = 0.f; + float emitterAge = 0.f; + bool active = false; + bool paused = false; + std::mt19937 rng; + }; + + struct Draw { + graphics::Texture *texture = nullptr; + int layer = 0; + bool visible = true; + }; + + /** Bound config file for hot reload (empty path = unbound). */ + struct Resource { + std::string path; + std::string texturePath; + int64_t modtime = -1; + bool autoReload = true; + }; + + COMPONENT(Config, config) + COMPONENT(Sim, sim) + COMPONENT(Draw, draw) + COMPONENT(Resource, resource) + + static ParticleEmitter *createEmitter(int bufferSize = 1000); + + void setPosition(float x, float y); + void moveTo(float x, float y); + float getX(); + float getY(); + + void setEmissionRate(float rate); + float getEmissionRate(); + + void setParticleLifetime(float minLife, float maxLife); + float getParticleLifetimeMin(); + float getParticleLifetimeMax(); + + void setEmitterLifetime(float seconds); + float getEmitterLifetime(); + + void setDirection(float radians); + float getDirection(); + + void setSpread(float radians); + float getSpread(); + + void setSpeed(float minSpeed, float maxSpeed); + void setLinearAcceleration(float xmin, float ymin, float xmax, float ymax); + + void setParticleSize(float width, float height); + float getParticleWidth(); + float getParticleHeight(); + + void setSizes(float startScale, float endScale); + + void setColorStart(float r, float g, float b, float a = 1.f); + void setColorEnd(float r, float g, float b, float a = 1.f); + + void setTexture(graphics::Texture *texture); + graphics::Texture *getTexture(); + + void setLayer(int layer); + int getLayer(); + + void setVisible(bool visible); + bool isVisible(); + + void start(); + void stop(); + void pause(); + void reset(); + void emit(int count); + + bool isActive(); + bool isPaused(); + bool isStopped(); + + int getCount(); + int getBufferSize(); + + /** Named preset: "spark" / "smoke" / "fire". Unknown → no-op. */ + void applyPreset(const std::string &name); + + /** Apply JSON text (object root). Returns false on parse/schema error. */ + bool applyConfig(const std::string &json); + /** Load & bind a config file (enables hot reload via ParticleConfigSystem). */ + bool loadConfig(const std::string &path); + /** Re-read bound config file. */ + bool reloadConfig(); + void setAutoReload(bool enable); + bool getAutoReload(); + std::string getConfigPath(); +}; + +/** Shared sim helpers (used by ParticleSimSystem / emit). */ +void spawnParticle(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim); +void stepEmitterSim(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim, float dt); + +} // namespace eve::particles diff --git a/src/modules/particles/ParticleSystem.cpp b/src/modules/particles/ParticleSystem.cpp new file mode 100644 index 00000000..bc6b362e --- /dev/null +++ b/src/modules/particles/ParticleSystem.cpp @@ -0,0 +1,131 @@ +#include "particles/ParticleSystem.h" +#include "particles/ParticleEmitter.h" +#include "particles/ParticleConfig.h" +#include "graphics/Graphics.h" +#include "filesystem/Filesystem.h" +#include "common/Module.h" + +#include +#include +#include + +namespace eve::particles { + +namespace { + +void sampleColor(const ParticleEmitter::Config &cfg, float t, Color &out) { + t = t < 0.f ? 0.f : (t > 1.f ? 1.f : t); + out.r = cfg.colorStart.r + (cfg.colorEnd.r - cfg.colorStart.r) * t; + out.g = cfg.colorStart.g + (cfg.colorEnd.g - cfg.colorStart.g) * t; + out.b = cfg.colorStart.b + (cfg.colorEnd.b - cfg.colorStart.b) * t; + out.a = cfg.colorStart.a + (cfg.colorEnd.a - cfg.colorStart.a) * t; +} + +void drawOne(const ParticleEmitter::Config &cfg, const ParticleEmitter::Sim &sim, + const ParticleEmitter::Draw &draw, graphics::Graphics *gfx) { + if (!gfx || !draw.visible || sim.alive <= 0) return; + + for (int i = 0; i < sim.alive; ++i) { + const Particle &p = sim.particles[size_t(i)]; + const float t = 1.f - (p.life / p.lifetime); + Color c; + sampleColor(cfg, t, c); + const float scale = cfg.sizeStart + (cfg.sizeEnd - cfg.sizeStart) * t; + const float w = cfg.particleW * scale; + const float h = cfg.particleH * scale; + const float hx = w * 0.5f; + const float hy = h * 0.5f; + if (draw.texture) + gfx->drawTexturedRect(draw.texture, p.x - hx, p.y - hy, w, h, c); + else + gfx->drawSolidRect(p.x - hx, p.y - hy, w, h, c); + } +} + +int64_t fileModtime(const std::string &path) { + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + eve::filesystem::Filesystem::Info info{}; + if (!fs->getInfo(path, info)) return -1; + return info.modtime; +} + +} // namespace + +void ParticleSimSystem::update(float dt) { + if (ecs::ComponentManager::inst().registy == nullptr) return; + + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, sim] = *it; + stepEmitterSim(*cfg, *sim, dt); + } +} + +void ParticleRenderSystem::render(graphics::Graphics *gfx) { + if (!gfx) return; + if (ecs::ComponentManager::inst().registy == nullptr) return; + + struct Item { + ParticleEmitter::Config *cfg; + ParticleEmitter::Sim *sim; + ParticleEmitter::Draw *draw; + }; + std::vector items; + + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, sim, draw] = *it; + if (!draw->visible || sim->alive <= 0) continue; + items.push_back(Item{cfg, sim, draw}); + } + + std::stable_sort(items.begin(), items.end(), [](const Item &a, const Item &b) { + return a.draw->layer < b.draw->layer; + }); + + for (const Item &it : items) drawOne(*it.cfg, *it.sim, *it.draw, gfx); +} + +int ParticleConfigSystem::poll() { + if (ecs::ComponentManager::inst().registy == nullptr) return 0; + + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + + // Drain filesystem watch events → mark matching emitters for reload. + std::vector dirtyPaths; + for (;;) { + std::string kind = fs->pollWatch(); + if (kind.empty()) break; + if (kind != "modified" && kind != "added" && kind != "movedTo") continue; + dirtyPaths.push_back(fs->getLastWatchPath()); + } + + int reloaded = 0; + auto view = + ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [cfg, res] = *it; + if (!res->autoReload || res->path.empty() || !cfg->entity) continue; + + bool dirty = false; + for (const auto &p : dirtyPaths) { + if (p == res->path) { + dirty = true; + break; + } + } + if (!dirty) { + // Fallback: mtime poll when watch missed the change. + const int64_t mt = fileModtime(res->path); + if (mt >= 0 && mt != res->modtime) dirty = true; + } + if (!dirty) continue; + if (reloadConfigFile(cfg->entity, nullptr)) ++reloaded; + } + return reloaded; +} + +} // namespace eve::particles diff --git a/src/modules/particles/ParticleSystem.h b/src/modules/particles/ParticleSystem.h new file mode 100644 index 00000000..59a302db --- /dev/null +++ b/src/modules/particles/ParticleSystem.h @@ -0,0 +1,30 @@ +#pragma once + +namespace eve::graphics { +class Graphics; +} + +namespace eve::particles { + +/** Advances all ParticleEmitter Sim components. */ +class ParticleSimSystem { +public: + static void update(float dt); +}; + +/** Draws all visible ParticleEmitter entities via Graphics batch path. */ +class ParticleRenderSystem { +public: + static void render(graphics::Graphics *gfx); +}; + +/** + * Polls bound config files (Resource.path) and hot-reloads when modtime changes. + * Returns number of emitters reloaded. + */ +class ParticleConfigSystem { +public: + static int poll(); +}; + +} // namespace eve::particles diff --git a/src/modules/particles/Particles.cpp b/src/modules/particles/Particles.cpp new file mode 100644 index 00000000..8f4e5811 --- /dev/null +++ b/src/modules/particles/Particles.cpp @@ -0,0 +1,147 @@ +#include "particles/Particles.h" +#include "particles/ParticleSystem.h" +#include "particles/ParticleConfig.h" +#include "graphics/Graphics.h" +#include "data/DataModule.h" +#include "data/JsonDocument.h" +#include "filesystem/Filesystem.h" +#include "filesystem/FileData.h" +#include "common/Module.h" + +#include +#include + +#include +#include + +namespace eve::particles { +namespace { + +int bufferFromJson(const std::string &json, int fallback) { + auto *dm = eve::data::DataModule::create(); + std::string err; + std::unique_ptr doc(dm->decodeJson(json, &err)); + if (!doc || !doc->isObject()) return fallback; + auto obj = doc->object(); + if (!obj || !obj->has("buffer")) return fallback; + try { + int n = obj->getValue("buffer"); + return n > 0 ? n : fallback; + } catch (...) { + return fallback; + } +} + +} // namespace + +Module_IMPL(Particles, new Particles()); + +ParticleEmitter *Particles::newEmitter(int bufferSize) { + return ParticleEmitter::createEmitter(bufferSize); +} + +ParticleEmitter *Particles::newEmitterFromFile(const std::string &path) { + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + + std::unique_ptr data; + try { + data.reset(fs->read(path)); + } catch (...) { + return nullptr; + } + if (!data || data->getSize() == 0) return nullptr; + + std::string text(static_cast(data->getData()), data->getSize()); + int buffer = bufferFromJson(text, 1000); + ParticleEmitter *e = ParticleEmitter::createEmitter(buffer); + if (!loadConfigFile(e, path, nullptr)) { + // Entity stays in ECS registry; still return for inspection, or null? + // Prefer null on failed apply after create — leave entity (ECS has no destroy). + return e; // path may have failed; emitter still usable + } + return e; +} + +void Particles::update(float dt) { + ParticleConfigSystem::poll(); + ParticleSimSystem::update(dt); +} + +void Particles::render(graphics::Graphics *gfx) { ParticleRenderSystem::render(gfx); } + +int Particles::pollConfigs() { return ParticleConfigSystem::poll(); } + +int Particles::getEmitterCount() const { + if (ecs::ComponentManager::inst().registy == nullptr) return 0; + int n = 0; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) ++n; + return n; +} + +void Particles::expose(ssq::Table &table) { + auto cls = table.addClass(name, Particles::create, false); + expose(cls); + + auto em = table.addClass( + "Emitter", std::function([]() -> ParticleEmitter * { return nullptr; }), + true); + em.addFunc("setPosition", &ParticleEmitter::setPosition); + em.addFunc("moveTo", &ParticleEmitter::moveTo); + em.addFunc("getX", &ParticleEmitter::getX); + em.addFunc("getY", &ParticleEmitter::getY); + em.addFunc("setEmissionRate", &ParticleEmitter::setEmissionRate); + em.addFunc("getEmissionRate", &ParticleEmitter::getEmissionRate); + em.addFunc("setParticleLifetime", &ParticleEmitter::setParticleLifetime); + em.addFunc("getParticleLifetimeMin", &ParticleEmitter::getParticleLifetimeMin); + em.addFunc("getParticleLifetimeMax", &ParticleEmitter::getParticleLifetimeMax); + em.addFunc("setEmitterLifetime", &ParticleEmitter::setEmitterLifetime); + em.addFunc("getEmitterLifetime", &ParticleEmitter::getEmitterLifetime); + em.addFunc("setDirection", &ParticleEmitter::setDirection); + em.addFunc("getDirection", &ParticleEmitter::getDirection); + em.addFunc("setSpread", &ParticleEmitter::setSpread); + em.addFunc("getSpread", &ParticleEmitter::getSpread); + em.addFunc("setSpeed", &ParticleEmitter::setSpeed); + em.addFunc("setLinearAcceleration", &ParticleEmitter::setLinearAcceleration); + em.addFunc("setParticleSize", &ParticleEmitter::setParticleSize); + em.addFunc("getParticleWidth", &ParticleEmitter::getParticleWidth); + em.addFunc("getParticleHeight", &ParticleEmitter::getParticleHeight); + em.addFunc("setSizes", &ParticleEmitter::setSizes); + em.addFunc("setColorStart", &ParticleEmitter::setColorStart); + em.addFunc("setColorEnd", &ParticleEmitter::setColorEnd); + em.addFunc("setTexture", &ParticleEmitter::setTexture); + em.addFunc("setLayer", &ParticleEmitter::setLayer); + em.addFunc("getLayer", &ParticleEmitter::getLayer); + em.addFunc("setVisible", &ParticleEmitter::setVisible); + em.addFunc("isVisible", &ParticleEmitter::isVisible); + em.addFunc("start", &ParticleEmitter::start); + em.addFunc("stop", &ParticleEmitter::stop); + em.addFunc("pause", &ParticleEmitter::pause); + em.addFunc("reset", &ParticleEmitter::reset); + em.addFunc("emit", &ParticleEmitter::emit); + em.addFunc("isActive", &ParticleEmitter::isActive); + em.addFunc("isPaused", &ParticleEmitter::isPaused); + em.addFunc("isStopped", &ParticleEmitter::isStopped); + em.addFunc("getCount", &ParticleEmitter::getCount); + em.addFunc("getBufferSize", &ParticleEmitter::getBufferSize); + em.addFunc("applyPreset", &ParticleEmitter::applyPreset); + em.addFunc("applyConfig", &ParticleEmitter::applyConfig); + em.addFunc("loadConfig", &ParticleEmitter::loadConfig); + em.addFunc("reloadConfig", &ParticleEmitter::reloadConfig); + em.addFunc("setAutoReload", &ParticleEmitter::setAutoReload); + em.addFunc("getAutoReload", &ParticleEmitter::getAutoReload); + em.addFunc("getConfigPath", &ParticleEmitter::getConfigPath); +} + +void Particles::expose(ssq::Class &cls) { + cls.addFunc("getName", &Particles::getName); + cls.addFunc("newEmitter", &Particles::newEmitter); + cls.addFunc("newEmitterFromFile", &Particles::newEmitterFromFile); + cls.addFunc("update", &Particles::update); + cls.addFunc("render", &Particles::render); + cls.addFunc("pollConfigs", &Particles::pollConfigs); + cls.addFunc("getEmitterCount", &Particles::getEmitterCount); +} + +} // namespace eve::particles diff --git a/src/modules/particles/Particles.h b/src/modules/particles/Particles.h new file mode 100644 index 00000000..4b7979bc --- /dev/null +++ b/src/modules/particles/Particles.h @@ -0,0 +1,37 @@ +#pragma once + +#include "common/Module.h" +#include "particles/ParticleEmitter.h" + +#include + +namespace eve::graphics { +class Graphics; +} + +namespace eve::particles { + +/** + * Particles module — factory + script binding. + * Per-frame: ParticleConfigSystem (hot reload) → ParticleSimSystem → + * ParticleRenderSystem. Module `update`/`render` forward to Systems. + */ +class Particles : public Module { +public: + Module_REG(Particles); + Particles() = default; + ~Particles() override = default; + + ParticleEmitter *newEmitter(int bufferSize = 1000); + /** Create emitter from JSON config file (reads optional "buffer"). */ + ParticleEmitter *newEmitterFromFile(const std::string &path); + + void update(float dt); + void render(graphics::Graphics *gfx); + /** Explicit hot-reload poll; also invoked from update(). */ + int pollConfigs(); + + int getEmitterCount() const; +}; + +} // namespace eve::particles diff --git a/src/modules/ui/Component.cpp b/src/modules/ui/Component.cpp new file mode 100644 index 00000000..da7d6d3a --- /dev/null +++ b/src/modules/ui/Component.cpp @@ -0,0 +1,33 @@ +#include "ui/Component.h" + +#include "ui/UISystem.h" + +namespace eve::ui { + +void Component::attach(UIHost *host) { + host_ = host; + dirty_ = true; +} + +void Component::mountAs(const std::string &hostName) { + UIHost *h = UISystem::findHost(hostName); + if (!h) h = UIHost::createHost(hostName); + attach(h); + rebuild(true); +} + +void Component::rebuild(bool forceFull) { + if (!host_) return; + WidgetDesc tree = build(); + if (forceFull) host_->setTree(std::move(tree)); + else host_->setTreeReconcile(std::move(tree)); + dirty_ = false; +} + +bool Component::updateIfDirty() { + if (!dirty_ || !host_) return false; + rebuild(false); + return true; +} + +} // namespace eve::ui diff --git a/src/modules/ui/Component.h b/src/modules/ui/Component.h new file mode 100644 index 00000000..ceb270f0 --- /dev/null +++ b/src/modules/ui/Component.h @@ -0,0 +1,43 @@ +#pragma once + +#include "ui/UIHost.h" +#include "ui/Widget.h" + +#include +#include + +namespace eve::ui { + +/** + * React-style UI component: implement build(), call setState / markDirty, then rebuild(). + * Mounts onto a named UIHost via mountAs / attach. + */ +class Component { +public: + virtual ~Component() = default; + + virtual WidgetDesc build() = 0; + + void attach(UIHost *host); + void mountAs(const std::string &hostName); + UIHost *host() const { return host_; } + + /** Rebuild tree onto host (reconcile by key when possible). */ + void rebuild(bool forceFull = false); + + void markDirty() { dirty_ = true; } + bool isDirty() const { return dirty_; } + + /** If dirty, rebuild and clear flag. Returns true if rebuilt. */ + bool updateIfDirty(); + +protected: + /** Subclasses call after mutating local state that affects build(). */ + void setState() { dirty_ = true; } + +private: + UIHost *host_ = nullptr; + bool dirty_ = true; +}; + +} // namespace eve::ui diff --git a/src/modules/ui/Theme.cpp b/src/modules/ui/Theme.cpp new file mode 100644 index 00000000..28c2f8b9 --- /dev/null +++ b/src/modules/ui/Theme.cpp @@ -0,0 +1,34 @@ +#include "ui/Theme.h" + +#include + +namespace eve::ui { +namespace { + +Theme g_theme; + +} // namespace + +Theme &globalTheme() { return g_theme; } + +void setGlobalTheme(const Theme &theme) { g_theme = theme; } + +void applyThemeToImGui(const Theme &theme) { + ImGuiStyle &style = ImGui::GetStyle(); + style.FrameRounding = theme.frameRounding; + style.Colors[ImGuiCol_WindowBg] = + ImVec4(theme.windowBg[0], theme.windowBg[1], theme.windowBg[2], theme.windowBg[3]); + style.Colors[ImGuiCol_Text] = ImVec4(theme.text[0], theme.text[1], theme.text[2], theme.text[3]); + style.Colors[ImGuiCol_Button] = + ImVec4(theme.button[0], theme.button[1], theme.button[2], theme.button[3]); + style.Colors[ImGuiCol_ButtonHovered] = ImVec4(theme.buttonHovered[0], theme.buttonHovered[1], + theme.buttonHovered[2], theme.buttonHovered[3]); + style.Colors[ImGuiCol_ButtonActive] = ImVec4(theme.buttonActive[0], theme.buttonActive[1], + theme.buttonActive[2], theme.buttonActive[3]); + + ImGuiIO &io = ImGui::GetIO(); + if (theme.navEnableKeyboard) io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + else io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableKeyboard; +} + +} // namespace eve::ui diff --git a/src/modules/ui/Theme.h b/src/modules/ui/Theme.h new file mode 100644 index 00000000..c667330a --- /dev/null +++ b/src/modules/ui/Theme.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace eve::ui { + +/** Simple style tokens applied before walking hosts each frame. */ +struct Theme { + float windowBg[4] = {0.06f, 0.06f, 0.07f, 0.94f}; + float text[4] = {1.f, 1.f, 1.f, 1.f}; + float button[4] = {0.26f, 0.59f, 0.98f, 0.40f}; + float buttonHovered[4] = {0.26f, 0.59f, 0.98f, 1.f}; + float buttonActive[4] = {0.06f, 0.53f, 0.98f, 1.f}; + float frameRounding = 3.f; + bool navEnableKeyboard = true; +}; + +Theme &globalTheme(); +void applyThemeToImGui(const Theme &theme); +void setGlobalTheme(const Theme &theme); + +} // namespace eve::ui diff --git a/src/modules/ui/UI.cpp b/src/modules/ui/UI.cpp new file mode 100644 index 00000000..3f8aeab8 --- /dev/null +++ b/src/modules/ui/UI.cpp @@ -0,0 +1,479 @@ +#include "ui/UI.h" + +#include "ui/Theme.h" +#include "ui/UISystem.h" +#include "ui/Widget.h" + +#include "common/Module.h" +#include "graphics/Graphics.h" +#include "window/Window.h" +#include "window/sdl/Window.h" + +#include + +#include +#include + +namespace eve::ui { +namespace { + +/** Script base: `class X extends eve.UIComponent { function build() { ... } }`. */ +const char *kUIComponentScript = R"SQ( +eve.UIComponent <- class { + hostName = "" + dirty = true + forceFull = false + _ui = null + + constructor(uiInstance = null) { + _ui = uiInstance + hostName = "" + dirty = true + forceFull = false + } + + function setUI(uiInstance) { _ui = uiInstance } + + function mountAs(name) { + hostName = name + dirty = true + forceFull = true + updateIfDirty() + } + + function setState() { dirty = true } + function markDirty() { dirty = true } + + // Override in subclass: call this.ui().beginWindow / text / button / end ... + function build() {} + + function ui() { + if (_ui != null) return _ui + try { + if (::ui != null) return ::ui + } catch (e) {} + _ui = ::eve.UI() + return _ui + } + + function updateIfDirty() { + if (!dirty) return false + local u = ui() + u.beginBuild() + build() + local name = hostName + if (name == null || name == "") name = "default" + if (forceFull) { + u.mountBuildAs(name) + forceFull = false + } else { + u.remountBuildAs(name) + } + dirty = false + return true + } + + function rebuild(force = false) { + dirty = true + forceFull = force + return updateIfDirty() + } +} +eve.Component <- eve.UIComponent +)SQ"; + +void injectUIComponentClass(ssq::Table &eveTable) { + HSQUIRRELVM vm = eveTable.getHandle(); + const SQInteger top = sq_gettop(vm); + if (SQ_FAILED(sq_compilebuffer(vm, kUIComponentScript, + static_cast(std::strlen(kUIComponentScript)), + "UIComponent.nut", SQTrue))) { + sq_settop(vm, top); + return; + } + sq_pushroottable(vm); + sq_call(vm, 1, SQFalse, SQTrue); + sq_settop(vm, top); +} + +} // namespace + +Module_IMPL(UI, new UI()); + +UI::UI() : backend_(createImGuiBackend()) {} +UI::~UI() { shutdownBackend(); } + +bool UI::isBackendReady() const { return backend_ && backend_->isInitialized(); } + +bool UI::initBackend() { + if (!backend_) backend_ = createImGuiBackend(); + if (backend_->isInitialized()) return true; + auto *win = eve::ModuleManager::getInstance("Window"); + auto *gfx = eve::ModuleManager::getInstance("Graphics"); + if (!win || !gfx) return false; + auto *sdlWin = dynamic_cast(win); + if (!sdlWin) return false; + auto *native = static_cast(sdlWin->getHandle()); + if (!native) return false; + return backend_->init(native, gfx); +} + +void UI::shutdownBackend() { + if (backend_) backend_->shutdown(); +} + +void UI::processEvent(const SDL_Event *event) { + if (backend_) backend_->processEvent(event); +} + +void UI::beginFrameAndRender() { + if (!isBackendReady()) { + if (!initBackend()) return; + } + backend_->newFrame(); + UISystem::render(); +} + +void UI::dispatchEvents() { UISystem::dispatchEvents(); } + +bool UI::wantCaptureMouse() const { + return backend_ ? backend_->wantCaptureMouse() : false; +} + +bool UI::wantCaptureKeyboard() const { + return backend_ ? backend_->wantCaptureKeyboard() : false; +} + +UIHost *UI::findHost(const std::string &name) const { return UISystem::findHost(name); } + +UIHost *UI::findHostByOwner(uint32_t ownerId) const { + return UISystem::findHostByOwner(ownerId); +} + +bool UI::select(const std::string &name) { + UIHost *h = findHost(name); + if (!h) return false; + selected_ = h; + return true; +} + +void UI::bindOwner(uint32_t ownerId) { + if (selected_) selected_->setOwnerId(ownerId); +} + +UIHost *UI::ensureSelected(const std::string &preferredName) { + if (selected_) return selected_; + if (!preferredName.empty()) { + if (UIHost *h = findHost(preferredName)) { + selected_ = h; + return selected_; + } + selected_ = UIHost::createHost(preferredName); + return selected_; + } + selected_ = UIHost::createHost("default"); + return selected_; +} + +UIHost *UI::mountAs(const std::string &name, WidgetDesc root) { + UIHost *h = findHost(name); + if (!h) h = UIHost::createHost(name); + else h->setName(name); + h->setTree(std::move(root)); + selected_ = h; + return h; +} + +UIHost *UI::mount(WidgetDesc root) { + if (selected_) { + selected_->setTree(std::move(root)); + return selected_; + } + return mountAs("default", std::move(root)); +} + +UIHost *UI::remount(WidgetDesc root) { + UIHost *h = ensureSelected(); + h->setTree(std::move(root)); + return h; +} + +UIHost *UI::remountReconcile(WidgetDesc root) { + UIHost *h = ensureSelected(); + h->setTreeReconcile(std::move(root)); + return h; +} + +UIHost *UI::remountAs(const std::string &name, WidgetDesc root) { + return mountAs(name, std::move(root)); +} + +void UI::beginBuild() { + openStack_.clear(); + hasBuiltRoot_ = false; + builtRoot_ = WidgetDesc{}; +} + +void UI::pushOpen(WidgetDesc d) { openStack_.push_back(std::move(d)); } + +WidgetDesc &UI::currentParent() { + if (openStack_.empty()) throw std::runtime_error("ui: widget outside beginWindow/beginGroup"); + return openStack_.back(); +} + +void UI::beginWindow(const std::string &title, const std::string &id) { + if (openStack_.empty() && hasBuiltRoot_) beginBuild(); + pushOpen(window(title, {}, id)); +} + +void UI::beginGroup(const std::string &id) { pushOpen(group({}, id)); } + +void UI::beginList(const std::string &id) { pushOpen(group({}, id)); } + +void UI::beginCollapsing(const std::string &label, const std::string &id, bool open) { + pushOpen(collapsingHeader(label, {}, id, open)); +} + +void UI::beginChild(const std::string &id, float width, float height) { + pushOpen(child(id, {}, width, height)); +} + +void UI::end() { + if (openStack_.empty()) throw std::runtime_error("ui: end() without begin"); + WidgetDesc finished = std::move(openStack_.back()); + openStack_.pop_back(); + if (openStack_.empty()) { + builtRoot_ = std::move(finished); + hasBuiltRoot_ = true; + } else { + openStack_.back().children.push_back(std::move(finished)); + } +} + +void UI::addText(const std::string &content, const std::string &id) { + currentParent().children.push_back(text(content, id)); +} + +void UI::addButton(const std::string &label, const std::string &id) { + currentParent().children.push_back(button(label, id)); +} + +void UI::addSameLine(const std::string &id) { currentParent().children.push_back(sameLine(id)); } + +void UI::addSeparator(const std::string &id) { + currentParent().children.push_back(separator(id)); +} + +void UI::addCheckbox(const std::string &label, bool checked, const std::string &id) { + currentParent().children.push_back(checkbox(label, checked, id)); +} + +void UI::addSlider(const std::string &label, float value, float minV, float maxV, + const std::string &id) { + currentParent().children.push_back(slider(label, value, minV, maxV, id)); +} + +void UI::addProgress(float fraction, const std::string &id, const std::string &overlay) { + currentParent().children.push_back(progress(fraction, id, overlay)); +} + +void UI::addInputText(const std::string &label, const std::string &value, const std::string &id) { + currentParent().children.push_back(inputText(label, value, id)); +} + +void UI::addListItem(const std::string &label, const std::string &id) { + WidgetDesc &parent = currentParent(); + std::string itemId = id; + if (itemId.empty()) + itemId = parent.id + "/" + std::to_string(parent.children.size()); + parent.children.push_back(button(label, itemId).withKey(itemId)); +} + +bool UI::buildComplete() const { return openStack_.empty() && hasBuiltRoot_; } + +bool UI::mountBuild() { + if (!buildComplete()) return false; + remount(std::move(builtRoot_)); + hasBuiltRoot_ = false; + builtRoot_ = WidgetDesc{}; + return true; +} + +bool UI::mountBuildAs(const std::string &name) { + if (!buildComplete()) return false; + mountAs(name, std::move(builtRoot_)); + hasBuiltRoot_ = false; + builtRoot_ = WidgetDesc{}; + return true; +} + +bool UI::remountBuildAs(const std::string &name) { + if (!buildComplete()) return false; + UIHost *h = findHost(name); + if (!h) h = UIHost::createHost(name); + h->setTreeReconcile(std::move(builtRoot_)); + selected_ = h; + hasBuiltRoot_ = false; + builtRoot_ = WidgetDesc{}; + return true; +} + +bool UI::setListItems(const std::string &listId, const std::vector &items) { + if (!selected_) return false; + WidgetDesc listNode = listButtons(listId, items); + auto *existing = selected_->findById(listId); + if (existing && existing->type == NodeType::Group) { + selected_->setTreeReconcile( + window(selected_->getName().empty() ? "List" : selected_->getName(), + {std::move(listNode)}, "root")); + return true; + } + selected_->setTree( + window(selected_->getName().empty() ? "List" : selected_->getName(), {std::move(listNode)}, + "root")); + return true; +} + +void UI::setText(const std::string &id, const std::string &text) { + if (selected_) selected_->setTextById(id, text); +} + +void UI::setVisible(const std::string &id, bool visible) { + if (selected_) selected_->setVisibleById(id, visible); +} + +void UI::setChecked(const std::string &id, bool checked) { + if (selected_) selected_->setCheckedById(id, checked); +} + +void UI::setValue(const std::string &id, float value) { + if (selected_) selected_->setValueById(id, value); +} + +void UI::setValueText(const std::string &id, const std::string &value) { + if (selected_) selected_->setValueTextById(id, value); +} + +float UI::getValue(const std::string &id) const { + if (!selected_) return 0.f; + if (auto *n = selected_->findById(id)) return n->value; + return 0.f; +} + +std::string UI::getValueText(const std::string &id) const { + if (!selected_) return {}; + if (auto *n = selected_->findById(id)) return n->valueText; + return {}; +} + +bool UI::getChecked(const std::string &id) const { + if (!selected_) return false; + if (auto *n = selected_->findById(id)) return n->checked; + return false; +} + +void UI::setHostVisible(bool visible) { + if (selected_) selected_->setVisible(visible); +} + +void UI::setHostLayer(int layer) { + if (selected_) selected_->setLayer(layer); +} + +void UI::setHostModal(bool modal) { + if (selected_) selected_->setModal(modal); +} + +std::string UI::consumeClick() { return UISystem::consumeClick(); } + +std::string UI::consumeChange() { return UISystem::consumeChange(); } + +void UI::setThemeDark() { + Theme t; + setGlobalTheme(t); +} + +void UI::setThemeLight() { + Theme t; + t.windowBg[0] = 0.94f; + t.windowBg[1] = 0.94f; + t.windowBg[2] = 0.94f; + t.windowBg[3] = 1.f; + t.text[0] = t.text[1] = t.text[2] = 0.f; + t.text[3] = 1.f; + t.button[0] = 0.26f; + t.button[1] = 0.59f; + t.button[2] = 0.98f; + t.button[3] = 0.6f; + setGlobalTheme(t); +} + +void UI::setNavKeyboard(bool enabled) { + globalTheme().navEnableKeyboard = enabled; +} + +void UI::mountSimple(const std::string &title, const std::string &labelText, + const std::string &buttonText) { + mountAs("default", window(title, {text(labelText, "label"), button(buttonText, "btn")}, "root")); +} + +void UI::expose(ssq::Table &table) { + auto cls = table.addClass(name, UI::create, false); + expose(cls); + injectUIComponentClass(table); +} + +void UI::expose(ssq::Class &cls) { + cls.addFunc("getName", &UI::getName); + cls.addFunc("initBackend", &UI::initBackend); + cls.addFunc("isBackendReady", &UI::isBackendReady); + cls.addFunc("beginFrameAndRender", &UI::beginFrameAndRender); + cls.addFunc("dispatchEvents", &UI::dispatchEvents); + cls.addFunc("wantCaptureMouse", &UI::wantCaptureMouse); + cls.addFunc("wantCaptureKeyboard", &UI::wantCaptureKeyboard); + + cls.addFunc("select", &UI::select); + cls.addFunc("bindOwner", &UI::bindOwner); + cls.addFunc("mountBuildAs", &UI::mountBuildAs); + cls.addFunc("remountBuildAs", &UI::remountBuildAs); + + cls.addFunc("beginBuild", &UI::beginBuild); + cls.addFunc("beginWindow", &UI::beginWindow); + cls.addFunc("beginGroup", &UI::beginGroup); + cls.addFunc("beginList", &UI::beginList); + cls.addFunc("beginCollapsing", &UI::beginCollapsing); + cls.addFunc("beginChild", &UI::beginChild); + cls.addFunc("end", &UI::end); + cls.addFunc("text", &UI::addText); + cls.addFunc("button", &UI::addButton); + cls.addFunc("sameLine", &UI::addSameLine); + cls.addFunc("separator", &UI::addSeparator); + cls.addFunc("checkbox", &UI::addCheckbox); + cls.addFunc("slider", &UI::addSlider); + cls.addFunc("progress", &UI::addProgress); + cls.addFunc("inputText", &UI::addInputText); + cls.addFunc("listItem", &UI::addListItem); + cls.addFunc("mountBuild", &UI::mountBuild); + + cls.addFunc("setText", &UI::setText); + cls.addFunc("setVisible", &UI::setVisible); + cls.addFunc("setChecked", &UI::setChecked); + cls.addFunc("setValue", &UI::setValue); + cls.addFunc("setValueText", &UI::setValueText); + cls.addFunc("getValue", &UI::getValue); + cls.addFunc("getValueText", &UI::getValueText); + cls.addFunc("getChecked", &UI::getChecked); + cls.addFunc("setHostVisible", &UI::setHostVisible); + cls.addFunc("setHostLayer", &UI::setHostLayer); + cls.addFunc("setHostModal", &UI::setHostModal); + cls.addFunc("consumeClick", &UI::consumeClick); + cls.addFunc("consumeChange", &UI::consumeChange); + + cls.addFunc("setThemeDark", &UI::setThemeDark); + cls.addFunc("setThemeLight", &UI::setThemeLight); + cls.addFunc("setNavKeyboard", &UI::setNavKeyboard); + + cls.addFunc("mountSimple", &UI::mountSimple); +} + +} // namespace eve::ui diff --git a/src/modules/ui/UI.h b/src/modules/ui/UI.h new file mode 100644 index 00000000..79da299e --- /dev/null +++ b/src/modules/ui/UI.h @@ -0,0 +1,119 @@ +#pragma once + +#include "common/Module.h" +#include "ui/UIBackend.h" +#include "ui/UIHost.h" +#include "ui/Widget.h" + +#include +#include +#include +#include + +namespace eve::graphics { +class Graphics; +} + +namespace eve::ui { + +/** + * Declarative UI module (eve.UI). + * + * ECS: named UIHost panels. Builder + Component.build (C++/script) + list/when/theme. + */ +class UI : public Module { +public: + Module_REG(UI); + UI(); + ~UI() override; + + bool initBackend(); + void shutdownBackend(); + bool isBackendReady() const; + + void processEvent(const SDL_Event *event); + void beginFrameAndRender(); + void dispatchEvents(); + + bool wantCaptureMouse() const; + bool wantCaptureKeyboard() const; + + UIHost *mountAs(const std::string &name, WidgetDesc root); + UIHost *mount(WidgetDesc root); + UIHost *remount(WidgetDesc root); + /** Remount with key reconcile (props-only when structure matches). */ + UIHost *remountReconcile(WidgetDesc root); + UIHost *remountAs(const std::string &name, WidgetDesc root); + + bool select(const std::string &name); + UIHost *findHost(const std::string &name) const; + UIHost *findHostByOwner(uint32_t ownerId) const; + UIHost *current() const { return selected_; } + void bindOwner(uint32_t ownerId); + + void beginBuild(); + void beginWindow(const std::string &title, const std::string &id = "root"); + void beginGroup(const std::string &id = ""); + void beginList(const std::string &id); + void beginCollapsing(const std::string &label, const std::string &id = "", bool open = true); + void beginChild(const std::string &id, float width = 0.f, float height = 120.f); + void end(); + void addText(const std::string &content, const std::string &id = ""); + void addButton(const std::string &label, const std::string &id = ""); + void addSameLine(const std::string &id = ""); + void addSeparator(const std::string &id = ""); + void addCheckbox(const std::string &label, bool checked, const std::string &id = ""); + void addSlider(const std::string &label, float value, float minV, float maxV, + const std::string &id = ""); + void addProgress(float fraction, const std::string &id = "", const std::string &overlay = ""); + void addInputText(const std::string &label, const std::string &value, const std::string &id = ""); + /** Append one list row button (call inside beginList). */ + void addListItem(const std::string &label, const std::string &id = ""); + + bool mountBuild(); + bool mountBuildAs(const std::string &name); + /** Like mountBuildAs but reconciles by key when possible. */ + bool remountBuildAs(const std::string &name); + + /** + * Replace children of a Group `listId` with buttons for each label (reconcile). + * Host must already exist and contain a group with that id. + */ + bool setListItems(const std::string &listId, const std::vector &items); + + void setText(const std::string &id, const std::string &text); + void setVisible(const std::string &id, bool visible); + void setChecked(const std::string &id, bool checked); + void setValue(const std::string &id, float value); + void setValueText(const std::string &id, const std::string &value); + float getValue(const std::string &id) const; + std::string getValueText(const std::string &id) const; + bool getChecked(const std::string &id) const; + void setHostVisible(bool visible); + void setHostLayer(int layer); + void setHostModal(bool modal); + std::string consumeClick(); + std::string consumeChange(); + + void setThemeDark(); + void setThemeLight(); + void setNavKeyboard(bool enabled); + + void mountSimple(const std::string &title, const std::string &labelText, + const std::string &buttonText); + +private: + WidgetDesc ¤tParent(); + void pushOpen(WidgetDesc d); + bool buildComplete() const; + UIHost *ensureSelected(const std::string &preferredName = ""); + + std::unique_ptr backend_; + UIHost *selected_ = nullptr; + + std::vector openStack_; + WidgetDesc builtRoot_; + bool hasBuiltRoot_ = false; +}; + +} // namespace eve::ui diff --git a/src/modules/ui/UIBackend.h b/src/modules/ui/UIBackend.h new file mode 100644 index 00000000..743083ab --- /dev/null +++ b/src/modules/ui/UIBackend.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +namespace eve::graphics { +class Graphics; +} + +namespace eve::ui { + +/** + * Platform / renderer backend for declarative UI. + * Concrete implementations live under ui// (e.g. ui/imgui). + */ +class UIBackend { +public: + virtual ~UIBackend() = default; + + virtual bool init(SDL_Window *window, eve::graphics::Graphics *gfx) = 0; + virtual void shutdown() = 0; + virtual bool isInitialized() const = 0; + + virtual void processEvent(const SDL_Event *event) = 0; + virtual void newFrame() = 0; + + virtual bool wantCaptureMouse() const { return false; } + virtual bool wantCaptureKeyboard() const { return false; } +}; + +/** Default backend: Dear ImGui + SDL + Vulkan (see ui/imgui/). */ +std::unique_ptr createImGuiBackend(); + +} // namespace eve::ui diff --git a/src/modules/ui/UIHost.cpp b/src/modules/ui/UIHost.cpp new file mode 100644 index 00000000..8f91951f --- /dev/null +++ b/src/modules/ui/UIHost.cpp @@ -0,0 +1,143 @@ +#include "ui/UIHost.h" +#include "ui/Widget.h" + +namespace eve::ui { +namespace { + +uint32_t g_anonHostSeq = 0; + +} // namespace + +UIHost *UIHost::createHost(const std::string &name) { + UIHost *h = UIHost::create(); + h->meta()->entity = h; + if (name.empty()) { + h->meta()->name = "host" + std::to_string(++g_anonHostSeq); + } else { + h->meta()->name = name; + } + return h; +} + +void UIHost::setName(const std::string &name) { meta()->name = name; } + +const std::string &UIHost::getName() { return meta()->name; } + +void UIHost::setTree(WidgetDesc root) { applyTree(this, std::move(root)); } + +bool UIHost::setTreeReconcile(WidgetDesc root) { return applyTreeReconcile(this, std::move(root)); } + +void UIHost::setSimplePanel(const std::string &title, const std::string &labelText, + const std::string &buttonText, const std::string &labelId, + const std::string &buttonId) { + setTree(window(title, + { + text(labelText, labelId), + button(buttonText, buttonId), + }, + "root")); +} + +UINode *UIHost::findById(const std::string &id) { + if (id.empty()) return nullptr; + auto t = tree(); + for (auto &n : t->nodes) { + if (n.id == id) return &n; + } + return nullptr; +} + +UINode *UIHost::findByKey(const std::string &key) { + if (key.empty()) return nullptr; + auto t = tree(); + for (auto &n : t->nodes) { + if (n.key == key) return &n; + } + return nullptr; +} + +void UIHost::setTextById(const std::string &id, const std::string &text) { + if (auto *n = findById(id)) n->text = text; +} + +void UIHost::setVisibleById(const std::string &id, bool visible) { + if (auto *n = findById(id)) n->visible = visible; +} + +void UIHost::setCheckedById(const std::string &id, bool checked) { + if (auto *n = findById(id)) n->checked = checked; +} + +void UIHost::setValueById(const std::string &id, float value) { + if (auto *n = findById(id)) n->value = value; +} + +void UIHost::setValueTextById(const std::string &id, const std::string &value) { + if (auto *n = findById(id)) n->valueText = value; +} + +bool UIHost::setClickHandler(const std::string &id, std::function fn) { + auto *n = findById(id); + if (!n) return false; + auto t = tree(); + if (n->handlerClick != 0) { + size_t idx = size_t(n->handlerClick - 1); + if (idx < t->clickHandlers.size()) { + t->clickHandlers[idx] = std::move(fn); + return true; + } + } + t->clickHandlers.push_back(std::move(fn)); + n->handlerClick = static_cast(t->clickHandlers.size()); + return true; +} + +bool UIHost::setToggleHandler(const std::string &id, std::function fn) { + auto *n = findById(id); + if (!n) return false; + auto t = tree(); + if (n->handlerToggle != 0) { + size_t idx = size_t(n->handlerToggle - 1); + if (idx < t->toggleHandlers.size()) { + t->toggleHandlers[idx] = std::move(fn); + return true; + } + } + t->toggleHandlers.push_back(std::move(fn)); + n->handlerToggle = static_cast(t->toggleHandlers.size()); + return true; +} + +bool UIHost::setValueHandler(const std::string &id, std::function fn) { + auto *n = findById(id); + if (!n) return false; + auto t = tree(); + if (n->handlerValue != 0) { + size_t idx = size_t(n->handlerValue - 1); + if (idx < t->valueHandlers.size()) { + t->valueHandlers[idx] = std::move(fn); + return true; + } + } + t->valueHandlers.push_back(std::move(fn)); + n->handlerValue = static_cast(t->valueHandlers.size()); + return true; +} + +bool UIHost::setTextHandler(const std::string &id, std::function fn) { + auto *n = findById(id); + if (!n) return false; + auto t = tree(); + if (n->handlerText != 0) { + size_t idx = size_t(n->handlerText - 1); + if (idx < t->textHandlers.size()) { + t->textHandlers[idx] = std::move(fn); + return true; + } + } + t->textHandlers.push_back(std::move(fn)); + n->handlerText = static_cast(t->textHandlers.size()); + return true; +} + +} // namespace eve::ui diff --git a/src/modules/ui/UIHost.h b/src/modules/ui/UIHost.h new file mode 100644 index 00000000..04225750 --- /dev/null +++ b/src/modules/ui/UIHost.h @@ -0,0 +1,118 @@ +#pragma once + +#include "common/ECS.h" + +#include +#include +#include +#include + +namespace eve::ui { + +enum class NodeType : uint8_t { + Window = 0, + Text = 1, + Button = 2, + SameLine = 3, + Group = 4, + Separator = 5, + Checkbox = 6, + Slider = 7, + Progress = 8, + InputText = 9, + CollapsingHeader = 10, + Child = 11, +}; + +struct UINode { + NodeType type = NodeType::Text; + std::string id; + std::string key; + std::string text; // label / title + std::string valueText; // InputText content + bool visible = true; + bool checked = false; + bool open = true; // CollapsingHeader default-open hint + float value = 0.f; + float minValue = 0.f; + float maxValue = 1.f; + float sizeX = 0.f; // Child size; 0 = auto + float sizeY = 0.f; + int firstChild = -1; + int nextSibling = -1; + uint32_t handlerClick = 0; // 1-based → Tree::clickHandlers + uint32_t handlerToggle = 0; // 1-based → Tree::toggleHandlers + uint32_t handlerValue = 0; // 1-based → Tree::valueHandlers + uint32_t handlerText = 0; // 1-based → Tree::textHandlers +}; + +struct WidgetDesc; + +/** + * ECS mount point for one UI panel/screen. + * Subclass to attach UI to game entities, e.g. `class Hud : public UIHost`. + * UISystem walks `ecs::View` (includes subclasses). + */ +class UIHost : public ecs::Entity { +public: + ENTITY(UIHost, ecs::Entity) + + void release() override {} + + struct Meta { + bool visible = true; + int layer = 0; + bool modal = false; + std::string name; + uint32_t ownerId = 0; + UIHost *entity = nullptr; + }; + + struct Tree { + bool dirty = true; + std::vector nodes; + int root = -1; + std::vector> clickHandlers; + std::vector> toggleHandlers; + std::vector> valueHandlers; + std::vector> textHandlers; + }; + + COMPONENT(Meta, meta) + COMPONENT(Tree, tree) + + static UIHost *createHost(const std::string &name = ""); + + void setName(const std::string &name); + const std::string &getName(); + void setOwnerId(uint32_t id) { meta()->ownerId = id; } + uint32_t getOwnerId() { return meta()->ownerId; } + + /** Full replace. */ + void setTree(WidgetDesc root); + /** Key-aware patch when structure matches; else full replace. */ + bool setTreeReconcile(WidgetDesc root); + + void setSimplePanel(const std::string &title, const std::string &labelText, + const std::string &buttonText, const std::string &labelId = "label", + const std::string &buttonId = "btn"); + + void setTextById(const std::string &id, const std::string &text); + void setVisibleById(const std::string &id, bool visible); + void setCheckedById(const std::string &id, bool checked); + void setValueById(const std::string &id, float value); + void setValueTextById(const std::string &id, const std::string &value); + bool setClickHandler(const std::string &id, std::function fn); + bool setToggleHandler(const std::string &id, std::function fn); + bool setValueHandler(const std::string &id, std::function fn); + bool setTextHandler(const std::string &id, std::function fn); + UINode *findById(const std::string &id); + UINode *findByKey(const std::string &key); + void markDirty() { tree()->dirty = true; } + + void setVisible(bool v) { meta()->visible = v; } + void setLayer(int layer) { meta()->layer = layer; } + void setModal(bool modal) { meta()->modal = modal; } +}; + +} // namespace eve::ui diff --git a/src/modules/ui/UISystem.cpp b/src/modules/ui/UISystem.cpp new file mode 100644 index 00000000..72476853 --- /dev/null +++ b/src/modules/ui/UISystem.cpp @@ -0,0 +1,270 @@ +#include "ui/UISystem.h" + +#include "ui/Theme.h" + +#include + +#include +#include +#include + +namespace eve::ui { +namespace { + +std::vector g_pending; +std::vector g_clicks; +std::vector g_changes; + +void pushPending(UIHost *host, const UINode &n, const char *kind, uint32_t handlerIndex, + bool toggleValue = false, float floatValue = 0.f, std::string textValue = {}) { + UIEvent ev; + ev.host = host; + ev.hostName = host ? host->meta()->name : ""; + ev.nodeId = n.id; + ev.kind = kind; + ev.handlerIndex = handlerIndex; + ev.toggleValue = toggleValue; + ev.floatValue = floatValue; + ev.textValue = std::move(textValue); + g_pending.push_back(std::move(ev)); +} + +void walk(UIHost *host, UIHost::Tree *tree, int index) { + if (index < 0 || index >= int(tree->nodes.size())) return; + UINode &n = tree->nodes[size_t(index)]; + if (!n.visible) { + if (n.nextSibling >= 0) walk(host, tree, n.nextSibling); + return; + } + + switch (n.type) { + case NodeType::Window: { + std::string title = n.text.empty() ? "Window" : n.text; + if (host && !host->meta()->name.empty()) title = host->meta()->name + "/" + title; + const bool modal = host && host->meta()->modal; + if (modal) { + ImGui::OpenPopup(title.c_str()); + if (ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + if (n.firstChild >= 0) walk(host, tree, n.firstChild); + ImGui::EndPopup(); + } + } else { + ImGui::Begin(title.c_str()); + if (n.firstChild >= 0) walk(host, tree, n.firstChild); + ImGui::End(); + } + break; + } + case NodeType::Text: + ImGui::TextUnformatted(n.text.c_str()); + break; + case NodeType::Button: + if (ImGui::Button(n.text.empty() ? "Button" : n.text.c_str())) { + pushPending(host, n, "click", n.handlerClick); + } + break; + case NodeType::SameLine: + ImGui::SameLine(); + break; + case NodeType::Group: + ImGui::BeginGroup(); + if (n.firstChild >= 0) walk(host, tree, n.firstChild); + ImGui::EndGroup(); + break; + case NodeType::Separator: + ImGui::Separator(); + break; + case NodeType::Checkbox: { + bool checked = n.checked; + if (ImGui::Checkbox(n.text.empty() ? "Check" : n.text.c_str(), &checked)) { + n.checked = checked; + pushPending(host, n, "toggle", n.handlerToggle, checked); + } + break; + } + case NodeType::Slider: { + float v = n.value; + const char *label = n.text.empty() ? "Slider" : n.text.c_str(); + if (ImGui::SliderFloat(label, &v, n.minValue, n.maxValue)) { + n.value = v; + pushPending(host, n, "value", n.handlerValue, false, v); + } + break; + } + case NodeType::Progress: { + const char *overlay = n.text.empty() ? nullptr : n.text.c_str(); + ImGui::ProgressBar(n.value, ImVec2(-1.f, 0.f), overlay); + break; + } + case NodeType::InputText: { + char buf[256]; + std::memset(buf, 0, sizeof(buf)); + if (!n.valueText.empty()) { + std::strncpy(buf, n.valueText.c_str(), sizeof(buf) - 1); + } + const char *label = n.text.empty() ? "Input" : n.text.c_str(); + if (ImGui::InputText(label, buf, sizeof(buf))) { + n.valueText = buf; + pushPending(host, n, "text", n.handlerText, false, 0.f, n.valueText); + } + break; + } + case NodeType::CollapsingHeader: { + ImGuiTreeNodeFlags flags = n.open ? ImGuiTreeNodeFlags_DefaultOpen : 0; + const char *label = n.text.empty() ? "Section" : n.text.c_str(); + if (ImGui::CollapsingHeader(label, flags)) { + if (n.firstChild >= 0) walk(host, tree, n.firstChild); + } + break; + } + case NodeType::Child: { + ImVec2 size(n.sizeX, n.sizeY); + const char *cid = n.id.empty() ? "child" : n.id.c_str(); + if (ImGui::BeginChild(cid, size, true)) { + if (n.firstChild >= 0) walk(host, tree, n.firstChild); + } + ImGui::EndChild(); + break; + } + } + + if (n.nextSibling >= 0) walk(host, tree, n.nextSibling); +} + +} // namespace + +std::vector &UISystem::pendingEvents() { return g_pending; } +std::vector &UISystem::clickQueue() { return g_clicks; } +std::vector &UISystem::changeQueue() { return g_changes; } + +UIHost *UISystem::findHost(const std::string &name) { + if (name.empty()) return nullptr; + if (ecs::ComponentManager::inst().registy == nullptr) return nullptr; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [meta, tree] = *it; + (void)tree; + if (meta->name == name) return meta->entity; + } + return nullptr; +} + +UIHost *UISystem::findHostByOwner(uint32_t ownerId) { + if (ownerId == 0) return nullptr; + if (ecs::ComponentManager::inst().registy == nullptr) return nullptr; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [meta, tree] = *it; + (void)tree; + if (meta->ownerId == ownerId) return meta->entity; + } + return nullptr; +} + +void UISystem::render() { + if (ecs::ComponentManager::inst().registy == nullptr) return; + + applyThemeToImGui(globalTheme()); + + struct Item { + UIHost *host; + UIHost::Meta *meta; + UIHost::Tree *tree; + }; + std::vector items; + + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [meta, tree] = *it; + if (!meta->visible) continue; + UIHost *host = meta->entity; + if (!host) continue; + items.push_back(Item{host, meta, tree}); + } + + std::stable_sort(items.begin(), items.end(), + [](const Item &a, const Item &b) { return a.meta->layer < b.meta->layer; }); + + for (auto &item : items) { + item.tree->dirty = false; + if (item.tree->root >= 0) walk(item.host, item.tree, item.tree->root); + } +} + +void UISystem::dispatchEvents() { + auto events = std::move(g_pending); + g_pending.clear(); + for (auto &ev : events) { + if (!ev.host) continue; + + if (ev.kind == "click" && !ev.nodeId.empty()) { + UIClick c; + c.hostName = ev.hostName.empty() ? ev.host->meta()->name : ev.hostName; + c.nodeId = ev.nodeId; + g_clicks.push_back(std::move(c)); + } + + if ((ev.kind == "toggle" || ev.kind == "value" || ev.kind == "text") && !ev.nodeId.empty()) { + UIChange ch; + ch.hostName = ev.hostName.empty() ? ev.host->meta()->name : ev.hostName; + ch.nodeId = ev.nodeId; + ch.kind = ev.kind; + g_changes.push_back(std::move(ch)); + } + + if (ev.kind == "toggle" && ev.handlerIndex != 0) { + auto t = ev.host->tree(); + size_t idx = size_t(ev.handlerIndex - 1); + if (idx < t->toggleHandlers.size() && t->toggleHandlers[idx]) + t->toggleHandlers[idx](ev.toggleValue); + continue; + } + if (ev.kind == "value" && ev.handlerIndex != 0) { + auto t = ev.host->tree(); + size_t idx = size_t(ev.handlerIndex - 1); + if (idx < t->valueHandlers.size() && t->valueHandlers[idx]) + t->valueHandlers[idx](ev.floatValue); + continue; + } + if (ev.kind == "text" && ev.handlerIndex != 0) { + auto t = ev.host->tree(); + size_t idx = size_t(ev.handlerIndex - 1); + if (idx < t->textHandlers.size() && t->textHandlers[idx]) + t->textHandlers[idx](ev.textValue); + continue; + } + if (ev.handlerIndex == 0) continue; + auto t = ev.host->tree(); + size_t idx = size_t(ev.handlerIndex - 1); + if (idx >= t->clickHandlers.size()) continue; + if (t->clickHandlers[idx]) t->clickHandlers[idx](); + } +} + +std::string UISystem::consumeClick() { + if (g_clicks.empty()) return {}; + UIClick c = std::move(g_clicks.front()); + g_clicks.erase(g_clicks.begin()); + if (c.hostName.empty()) return c.nodeId; + return c.hostName + "/" + c.nodeId; +} + +std::string UISystem::consumeClickFor(const std::string &hostName) { + for (auto it = g_clicks.begin(); it != g_clicks.end(); ++it) { + if (it->hostName != hostName) continue; + std::string id = std::move(it->nodeId); + g_clicks.erase(it); + return id; + } + return {}; +} + +std::string UISystem::consumeChange() { + if (g_changes.empty()) return {}; + UIChange c = std::move(g_changes.front()); + g_changes.erase(g_changes.begin()); + if (c.hostName.empty()) return c.nodeId; + return c.hostName + "/" + c.nodeId; +} + +} // namespace eve::ui diff --git a/src/modules/ui/UISystem.h b/src/modules/ui/UISystem.h new file mode 100644 index 00000000..02e7a16f --- /dev/null +++ b/src/modules/ui/UISystem.h @@ -0,0 +1,57 @@ +#pragma once + +#include "ui/UIHost.h" + +#include +#include + +namespace eve::ui { + +struct UIEvent { + UIHost *host = nullptr; + std::string hostName; + std::string nodeId; + std::string kind; // "click" | "toggle" | "value" | "text" + uint32_t handlerIndex = 0; + bool toggleValue = false; + float floatValue = 0.f; + std::string textValue; +}; + +/** One script-facing click: "hostName/nodeId". */ +struct UIClick { + std::string hostName; + std::string nodeId; +}; + +/** Value/text/toggle change for script: "hostName/nodeId". */ +struct UIChange { + std::string hostName; + std::string nodeId; + std::string kind; +}; + +class UISystem { +public: + /** Walk all UIHost (+ subclasses) via ECS View. */ + static void render(); + + static std::vector &pendingEvents(); + static void dispatchEvents(); + + /** Lookup by Meta.name across the UIHost View. */ + static UIHost *findHost(const std::string &name); + /** First host with Meta.ownerId == ownerId, or nullptr. */ + static UIHost *findHostByOwner(uint32_t ownerId); + + static std::vector &clickQueue(); + static std::vector &changeQueue(); + /** Pop next click as "name/node"; empty if none. */ + static std::string consumeClick(); + /** Pop next click for a specific host; returns node id only (or ""). */ + static std::string consumeClickFor(const std::string &hostName); + /** Pop next change as "name/node"; empty if none. */ + static std::string consumeChange(); +}; + +} // namespace eve::ui diff --git a/src/modules/ui/Widget.cpp b/src/modules/ui/Widget.cpp new file mode 100644 index 00000000..c912b026 --- /dev/null +++ b/src/modules/ui/Widget.cpp @@ -0,0 +1,324 @@ +#include "ui/Widget.h" + +namespace eve::ui { +namespace { + +int appendNode(UIHost::Tree &tree, WidgetDesc &&desc) { + const int index = int(tree.nodes.size()); + UINode node; + node.type = desc.type; + node.id = std::move(desc.id); + node.key = desc.key.empty() ? node.id : std::move(desc.key); + node.text = std::move(desc.text); + node.valueText = std::move(desc.valueText); + node.visible = desc.visible; + node.checked = desc.checked; + node.open = desc.open; + node.value = desc.value; + node.minValue = desc.minValue; + node.maxValue = desc.maxValue; + node.sizeX = desc.sizeX; + node.sizeY = desc.sizeY; + node.firstChild = -1; + node.nextSibling = -1; + node.handlerClick = 0; + node.handlerToggle = 0; + node.handlerValue = 0; + node.handlerText = 0; + + if (desc.onClick) { + tree.clickHandlers.push_back(std::move(desc.onClick)); + node.handlerClick = static_cast(tree.clickHandlers.size()); + } + if (desc.onToggle) { + tree.toggleHandlers.push_back(std::move(desc.onToggle)); + node.handlerToggle = static_cast(tree.toggleHandlers.size()); + } + if (desc.onValue) { + tree.valueHandlers.push_back(std::move(desc.onValue)); + node.handlerValue = static_cast(tree.valueHandlers.size()); + } + if (desc.onTextChange) { + tree.textHandlers.push_back(std::move(desc.onTextChange)); + node.handlerText = static_cast(tree.textHandlers.size()); + } + + tree.nodes.push_back(std::move(node)); + + int prevChild = -1; + int firstChild = -1; + for (auto &child : desc.children) { + int childIndex = appendNode(tree, std::move(child)); + if (firstChild < 0) firstChild = childIndex; + if (prevChild >= 0) tree.nodes[size_t(prevChild)].nextSibling = childIndex; + prevChild = childIndex; + } + tree.nodes[size_t(index)].firstChild = firstChild; + return index; +} + +bool structureMatches(const UIHost::Tree &tree, int nodeIndex, const WidgetDesc &desc) { + if (nodeIndex < 0 || nodeIndex >= int(tree.nodes.size())) return false; + const UINode &n = tree.nodes[size_t(nodeIndex)]; + if (n.type != desc.type) return false; + const std::string &dk = desc.reconcileKey(); + if (!dk.empty() && !n.key.empty() && n.key != dk) return false; + + std::vector oldKeys; + for (int c = n.firstChild; c >= 0; c = tree.nodes[size_t(c)].nextSibling) { + oldKeys.push_back(tree.nodes[size_t(c)].key); + } + if (oldKeys.size() != desc.children.size()) return false; + for (size_t i = 0; i < desc.children.size(); ++i) { + const std::string &ck = desc.children[i].reconcileKey(); + if (ck.empty() || oldKeys[i].empty()) { + if (!(ck.empty() && oldKeys[i].empty())) return false; + } else if (ck != oldKeys[i]) { + return false; + } + } + + int child = n.firstChild; + for (const auto &ch : desc.children) { + if (!structureMatches(tree, child, ch)) return false; + child = tree.nodes[size_t(child)].nextSibling; + } + return true; +} + +void patchProps(UIHost::Tree &tree, int nodeIndex, WidgetDesc &&desc) { + UINode &n = tree.nodes[size_t(nodeIndex)]; + n.text = std::move(desc.text); + n.valueText = std::move(desc.valueText); + n.visible = desc.visible; + n.checked = desc.checked; + n.open = desc.open; + n.value = desc.value; + n.minValue = desc.minValue; + n.maxValue = desc.maxValue; + n.sizeX = desc.sizeX; + n.sizeY = desc.sizeY; + if (!desc.id.empty()) n.id = desc.id; + + if (desc.onClick) { + if (n.handlerClick != 0) { + tree.clickHandlers[size_t(n.handlerClick - 1)] = std::move(desc.onClick); + } else { + tree.clickHandlers.push_back(std::move(desc.onClick)); + n.handlerClick = static_cast(tree.clickHandlers.size()); + } + } + if (desc.onToggle) { + if (n.handlerToggle != 0) { + tree.toggleHandlers[size_t(n.handlerToggle - 1)] = std::move(desc.onToggle); + } else { + tree.toggleHandlers.push_back(std::move(desc.onToggle)); + n.handlerToggle = static_cast(tree.toggleHandlers.size()); + } + } + if (desc.onValue) { + if (n.handlerValue != 0) { + tree.valueHandlers[size_t(n.handlerValue - 1)] = std::move(desc.onValue); + } else { + tree.valueHandlers.push_back(std::move(desc.onValue)); + n.handlerValue = static_cast(tree.valueHandlers.size()); + } + } + if (desc.onTextChange) { + if (n.handlerText != 0) { + tree.textHandlers[size_t(n.handlerText - 1)] = std::move(desc.onTextChange); + } else { + tree.textHandlers.push_back(std::move(desc.onTextChange)); + n.handlerText = static_cast(tree.textHandlers.size()); + } + } + + int child = n.firstChild; + for (auto &ch : desc.children) { + patchProps(tree, child, std::move(ch)); + child = tree.nodes[size_t(child)].nextSibling; + } +} + +} // namespace + +WidgetDesc window(std::string title, std::vector children, std::string id) { + WidgetDesc d; + d.type = NodeType::Window; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(title); + d.children = std::move(children); + return d; +} + +WidgetDesc text(std::string content, std::string id) { + WidgetDesc d; + d.type = NodeType::Text; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(content); + return d; +} + +WidgetDesc button(std::string label, std::string id, std::function onClick) { + WidgetDesc d; + d.type = NodeType::Button; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(label); + d.onClick = std::move(onClick); + return d; +} + +WidgetDesc group(std::vector children, std::string id) { + WidgetDesc d; + d.type = NodeType::Group; + d.id = std::move(id); + d.key = d.id; + d.children = std::move(children); + return d; +} + +WidgetDesc sameLine(std::string id) { + WidgetDesc d; + d.type = NodeType::SameLine; + d.id = std::move(id); + d.key = d.id; + return d; +} + +WidgetDesc separator(std::string id) { + WidgetDesc d; + d.type = NodeType::Separator; + d.id = std::move(id); + d.key = d.id; + return d; +} + +WidgetDesc checkbox(std::string label, bool checked, std::string id, + std::function onToggle) { + WidgetDesc d; + d.type = NodeType::Checkbox; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(label); + d.checked = checked; + d.onToggle = std::move(onToggle); + return d; +} + +WidgetDesc slider(std::string label, float value, float minV, float maxV, std::string id, + std::function onValue) { + WidgetDesc d; + d.type = NodeType::Slider; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(label); + d.value = value; + d.minValue = minV; + d.maxValue = maxV; + d.onValue = std::move(onValue); + return d; +} + +WidgetDesc progress(float fraction, std::string id, std::string overlay) { + WidgetDesc d; + d.type = NodeType::Progress; + d.id = std::move(id); + d.key = d.id; + d.value = fraction; + d.text = std::move(overlay); + return d; +} + +WidgetDesc inputText(std::string label, std::string value, std::string id, + std::function onChange) { + WidgetDesc d; + d.type = NodeType::InputText; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(label); + d.valueText = std::move(value); + d.onTextChange = std::move(onChange); + return d; +} + +WidgetDesc collapsingHeader(std::string label, std::vector children, std::string id, + bool defaultOpen) { + WidgetDesc d; + d.type = NodeType::CollapsingHeader; + d.id = std::move(id); + d.key = d.id; + d.text = std::move(label); + d.open = defaultOpen; + d.children = std::move(children); + return d; +} + +WidgetDesc child(std::string id, std::vector children, float width, float height) { + WidgetDesc d; + d.type = NodeType::Child; + d.id = std::move(id); + d.key = d.id; + d.sizeX = width; + d.sizeY = height; + d.children = std::move(children); + return d; +} + +WidgetDesc when(bool cond, WidgetDesc child) { + if (!cond) return group({}, "__when_empty"); + return child; +} + +WidgetDesc whenElse(bool cond, WidgetDesc ifTrue, WidgetDesc ifFalse) { + return cond ? std::move(ifTrue) : std::move(ifFalse); +} + +WidgetDesc list(std::string listId, const std::vector &items, + const std::function &itemFn) { + std::vector children; + children.reserve(items.size()); + for (int i = 0; i < int(items.size()); ++i) { + WidgetDesc row = itemFn(items[size_t(i)], i); + if (row.id.empty()) row.id = listId + "/" + std::to_string(i); + if (row.key.empty()) row.key = row.id; + children.push_back(std::move(row)); + } + return group(std::move(children), std::move(listId)); +} + +WidgetDesc listButtons(std::string listId, const std::vector &items) { + std::string lid = listId; + return list(std::move(listId), items, [lid](const std::string &label, int i) { + return button(label, lid + "/" + std::to_string(i)).withKey(lid + "/" + std::to_string(i)); + }); +} + +void applyTree(UIHost *host, WidgetDesc root) { + if (!host) return; + auto t = host->tree(); + t->nodes.clear(); + t->clickHandlers.clear(); + t->toggleHandlers.clear(); + t->valueHandlers.clear(); + t->textHandlers.clear(); + t->root = -1; + t->root = appendNode(*t, std::move(root)); + t->dirty = true; +} + +bool applyTreeReconcile(UIHost *host, WidgetDesc root) { + if (!host) return true; + auto t = host->tree(); + if (t->root < 0 || t->nodes.empty() || !structureMatches(*t, t->root, root)) { + applyTree(host, std::move(root)); + return true; + } + patchProps(*t, t->root, std::move(root)); + t->dirty = false; + return false; +} + +} // namespace eve::ui diff --git a/src/modules/ui/Widget.h b/src/modules/ui/Widget.h new file mode 100644 index 00000000..82395a13 --- /dev/null +++ b/src/modules/ui/Widget.h @@ -0,0 +1,141 @@ +#pragma once + +#include "ui/UIHost.h" + +#include +#include +#include +#include + +namespace eve::ui { + +/** Declarative widget description (build once / on dirty → flatten into UIHost::Tree). */ +struct WidgetDesc { + NodeType type = NodeType::Text; + std::string id; + /** Reconciliation key; defaults to id when empty. */ + std::string key; + std::string text; + std::string valueText; + bool visible = true; + bool checked = false; + bool open = true; + float value = 0.f; + float minValue = 0.f; + float maxValue = 1.f; + float sizeX = 0.f; + float sizeY = 0.f; + std::function onClick; + std::function onToggle; + std::function onValue; + std::function onTextChange; + std::vector children; + + WidgetDesc &withId(std::string v) { + id = std::move(v); + return *this; + } + WidgetDesc &withKey(std::string v) { + key = std::move(v); + return *this; + } + WidgetDesc &withText(std::string v) { + text = std::move(v); + return *this; + } + WidgetDesc &withValueText(std::string v) { + valueText = std::move(v); + return *this; + } + WidgetDesc &withVisible(bool v) { + visible = v; + return *this; + } + WidgetDesc &withChecked(bool v) { + checked = v; + return *this; + } + WidgetDesc &withOpen(bool v) { + open = v; + return *this; + } + WidgetDesc &withValue(float v) { + value = v; + return *this; + } + WidgetDesc &withRange(float lo, float hi) { + minValue = lo; + maxValue = hi; + return *this; + } + WidgetDesc &withSize(float w, float h) { + sizeX = w; + sizeY = h; + return *this; + } + WidgetDesc &withClick(std::function fn) { + onClick = std::move(fn); + return *this; + } + WidgetDesc &withToggle(std::function fn) { + onToggle = std::move(fn); + return *this; + } + WidgetDesc &withValueFn(std::function fn) { + onValue = std::move(fn); + return *this; + } + WidgetDesc &withTextChange(std::function fn) { + onTextChange = std::move(fn); + return *this; + } + WidgetDesc &child(WidgetDesc c) { + children.push_back(std::move(c)); + return *this; + } + + const std::string &reconcileKey() const { return key.empty() ? id : key; } +}; + +WidgetDesc window(std::string title, std::vector children = {}, std::string id = "root"); +WidgetDesc text(std::string content, std::string id = ""); +WidgetDesc button(std::string label, std::string id = "", std::function onClick = {}); +WidgetDesc group(std::vector children = {}, std::string id = ""); +WidgetDesc sameLine(std::string id = ""); +WidgetDesc separator(std::string id = ""); +WidgetDesc checkbox(std::string label, bool checked = false, std::string id = "", + std::function onToggle = {}); +WidgetDesc slider(std::string label, float value, float minV, float maxV, std::string id = "", + std::function onValue = {}); +WidgetDesc progress(float fraction, std::string id = "", std::string overlay = ""); +WidgetDesc inputText(std::string label, std::string value, std::string id = "", + std::function onChange = {}); +WidgetDesc collapsingHeader(std::string label, std::vector children = {}, + std::string id = "", bool defaultOpen = true); +WidgetDesc child(std::string id, std::vector children = {}, float width = 0.f, + float height = 120.f); + +/** Conditional: include `child` only when `cond` is true (empty group otherwise). */ +WidgetDesc when(bool cond, WidgetDesc child); +WidgetDesc whenElse(bool cond, WidgetDesc ifTrue, WidgetDesc ifFalse); + +/** + * Expand a string list into a Group of item widgets. + * `itemFn(label, index)` builds each row; keys default to id. + */ +WidgetDesc list(std::string listId, const std::vector &items, + const std::function &itemFn); + +/** Default list: one Button per item, id = listId + "/" + index. */ +WidgetDesc listButtons(std::string listId, const std::vector &items); + +/** Full replace flatten. */ +void applyTree(UIHost *host, WidgetDesc root); + +/** + * Patch by key/id when structure (type + child keys) matches; otherwise full replace. + * Returns true if a structural rebuild occurred. + */ +bool applyTreeReconcile(UIHost *host, WidgetDesc root); + +} // namespace eve::ui diff --git a/src/modules/ui/imgui/ImGuiBackend.cpp b/src/modules/ui/imgui/ImGuiBackend.cpp new file mode 100644 index 00000000..97c283b3 --- /dev/null +++ b/src/modules/ui/imgui/ImGuiBackend.cpp @@ -0,0 +1,161 @@ +#include "ui/imgui/ImGuiBackend.h" + +#include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/vulkan/Graphics.h" +#include "vkbuilder.hpp" + +#include +#include +#include + +#include + +namespace eve::ui { +namespace { + +void checkVk(VkResult err) { + if (err == 0) return; + throw eve::Exception("ImGui Vulkan error: VkResult = %d", int(err)); +} + +} // namespace + +std::unique_ptr createImGuiBackend() { + return std::make_unique(); +} + +ImGuiBackend::~ImGuiBackend() { shutdown(); } + +bool ImGuiBackend::init(SDL_Window *window, eve::graphics::Graphics *gfx) { + if (initialized_) return true; + if (!window || !gfx) return false; + + auto *vkg = dynamic_cast(gfx); + if (!vkg) return false; + if (!vkg->getSwapchainRenderPass()) return false; + + gfx_ = gfx; + window_ = window; + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::StyleColorsDark(); + + ImGui_ImplSDL2_InitForVulkan(window); + + auto &device = vkg->getDevice(); + VkDescriptorPoolSize poolSizes[] = { + {VK_DESCRIPTOR_TYPE_SAMPLER, 1000}, + {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1000}, + {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1000}, + {VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1000}, + {VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER, 1000}, + {VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, 1000}, + {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1000}, + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1000}, + {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 1000}, + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, 1000}, + {VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT, 1000}, + }; + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + poolInfo.maxSets = 1000 * uint32_t(sizeof(poolSizes) / sizeof(poolSizes[0])); + poolInfo.poolSizeCount = uint32_t(sizeof(poolSizes) / sizeof(poolSizes[0])); + poolInfo.pPoolSizes = poolSizes; + + VkDescriptorPool pool = VK_NULL_HANDLE; + checkVk(vkCreateDescriptorPool(static_cast(device.instance), &poolInfo, nullptr, &pool)); + imguiDescriptorPool_ = pool; + + uint32_t imageCount = vkg->getSwapchainImageCount(); + if (imageCount < 2) imageCount = 2; + + ImGui_ImplVulkan_InitInfo initInfo{}; + initInfo.Instance = static_cast(vkg->getInstance().instance); + initInfo.PhysicalDevice = static_cast(device.physical_device.instance); + initInfo.Device = static_cast(device.instance); + initInfo.QueueFamily = device.get_queue_index(vkb::QueueType::graphics); + initInfo.Queue = static_cast(device.getQueue(vkb::QueueType::graphics)); + initInfo.PipelineCache = VK_NULL_HANDLE; + initInfo.DescriptorPool = pool; + initInfo.MinImageCount = imageCount; + initInfo.ImageCount = imageCount; + initInfo.MSAASamples = VK_SAMPLE_COUNT_1_BIT; + initInfo.CheckVkResultFn = [](VkResult err) { + if (err != 0) fprintf(stderr, "ImGui Vulkan: VkResult %d\n", int(err)); + }; + + ImGui_ImplVulkan_Init(&initInfo, static_cast(vkg->getSwapchainRenderPass())); + + vkb::executeImmediately(device.instance, vkg->getUploadPool(), + device.getQueue(vkb::QueueType::graphics), [&](vk::CommandBuffer cb) { + ImGui_ImplVulkan_CreateFontsTexture(static_cast(cb)); + }); + ImGui_ImplVulkan_DestroyFontUploadObjects(); + fontsUploaded_ = true; + + gfx_->setPresentOverlay(&ImGuiBackend::presentOverlayThunk, this); + initialized_ = true; + return true; +} + +void ImGuiBackend::shutdown() { + if (!initialized_) return; + if (gfx_) { + if (gfx_->getPresentOverlayUser() == this) gfx_->setPresentOverlay(nullptr, nullptr); + } + auto *vkg = dynamic_cast(gfx_); + if (vkg) { + vkDeviceWaitIdle(static_cast(vkg->getDevice().instance)); + } + ImGui_ImplVulkan_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImGui::DestroyContext(); + if (imguiDescriptorPool_ && vkg) { + vkDestroyDescriptorPool(static_cast(vkg->getDevice().instance), + static_cast(imguiDescriptorPool_), nullptr); + imguiDescriptorPool_ = nullptr; + } + gfx_ = nullptr; + window_ = nullptr; + fontsUploaded_ = false; + initialized_ = false; +} + +void ImGuiBackend::processEvent(const SDL_Event *event) { + if (!initialized_ || !event) return; + ImGui_ImplSDL2_ProcessEvent(event); +} + +void ImGuiBackend::newFrame() { + if (!initialized_ || !window_) return; + ImGui_ImplVulkan_NewFrame(); + ImGui_ImplSDL2_NewFrame(window_); + ImGui::NewFrame(); +} + +bool ImGuiBackend::wantCaptureMouse() const { + if (!initialized_) return false; + return ImGui::GetIO().WantCaptureMouse; +} + +bool ImGuiBackend::wantCaptureKeyboard() const { + if (!initialized_) return false; + return ImGui::GetIO().WantCaptureKeyboard; +} + +void ImGuiBackend::renderDrawData(void *vkCommandBuffer) { + if (!initialized_ || !vkCommandBuffer) return; + ImGui::Render(); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), + static_cast(vkCommandBuffer)); +} + +void ImGuiBackend::presentOverlayThunk(void *userdata, void *commandBuffer) { + auto *self = static_cast(userdata); + if (!self) return; + self->renderDrawData(commandBuffer); +} + +} // namespace eve::ui diff --git a/src/modules/ui/imgui/ImGuiBackend.h b/src/modules/ui/imgui/ImGuiBackend.h new file mode 100644 index 00000000..79f8ca10 --- /dev/null +++ b/src/modules/ui/imgui/ImGuiBackend.h @@ -0,0 +1,37 @@ +#pragma once + +#include "ui/UIBackend.h" + +namespace eve::ui { + +/** Dear ImGui + SDL input + Vulkan present overlay. */ +class ImGuiBackend final : public UIBackend { +public: + ImGuiBackend() = default; + ~ImGuiBackend() override; + + ImGuiBackend(const ImGuiBackend &) = delete; + ImGuiBackend &operator=(const ImGuiBackend &) = delete; + + bool init(SDL_Window *window, eve::graphics::Graphics *gfx) override; + void shutdown() override; + bool isInitialized() const override { return initialized_; } + + void processEvent(const SDL_Event *event) override; + void newFrame() override; + + bool wantCaptureMouse() const override; + bool wantCaptureKeyboard() const override; + +private: + void renderDrawData(void *vkCommandBuffer); + static void presentOverlayThunk(void *userdata, void *commandBuffer); + + bool initialized_ = false; + bool fontsUploaded_ = false; + eve::graphics::Graphics *gfx_ = nullptr; + SDL_Window *window_ = nullptr; + void *imguiDescriptorPool_ = nullptr; // VkDescriptorPool +}; + +} // namespace eve::ui diff --git a/src/scripts/load.nut b/src/scripts/load.nut index a24a8850..fac2d063 100644 --- a/src/scripts/load.nut +++ b/src/scripts/load.nut @@ -33,18 +33,53 @@ if (!win.setWindowSettings(s)) { event <- eve.Event(); timer <- eve.Timer(); +ui <- eve.UI(); demo <- { x = 40.0, vx = 80.0 }; -eve_init <- function() {}; +eve_init <- function() { + // Named ECS hosts: hud + menu; select before props/clicks. + ui.beginBuild(); + ui.beginWindow("HUD", "root"); + ui.text("HP 100", "hp"); + ui.button("Pause", "pause"); + ui.end(); + ui.mountBuildAs("hud"); + + ui.beginBuild(); + ui.beginWindow("Menu", "root"); + ui.button("Resume", "resume"); + ui.end(); + ui.mountBuildAs("menu"); + ui.setHostVisible(false); // menu starts hidden (selected=menu) + + ui.select("hud"); +}; eve_update <- function(dt) { demo.x += demo.vx * dt; if (demo.x < 0.0 || demo.x + 120.0 > config.width) demo.vx = -demo.vx; + + local id = ui.consumeClick(); + while (id != "") { + if (id == "hud/pause") { + ui.select("hud"); + ui.setHostVisible(false); + ui.select("menu"); + ui.setHostVisible(true); + } else if (id == "menu/resume") { + ui.select("menu"); + ui.setHostVisible(false); + ui.select("hud"); + ui.setHostVisible(true); + } + id = ui.consumeClick(); + } }; eve_render <- function() { gfx.clear(); gfx.drawSolidRect(demo.x, 40.0, 120.0, 80.0, 1.0, 0.4, 0.2, 1.0); + ui.beginFrameAndRender(); }; eve_quit <- function() {}; @@ -67,6 +102,7 @@ while (running) { eve_update(dt); eve_render(); gfx.present(); + ui.dispatchEvents(); } eve_quit(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6ef8e172..b5bfd860 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -16,6 +16,12 @@ file(GLOB all_test_cpp network.cpp RenderSystem.cpp RenderSystem3D.cpp + UISystem.cpp + UIBuilder.cpp + UIPhaseB.cpp + UIPhaseAdv.cpp + UIOverlaySmoke.cpp + particles.cpp simplesquirrel.cpp sound.cpp audio.cpp @@ -111,7 +117,7 @@ else() endif() endif() -set(EVELIBS EVCmdLine EVScripts EVWindow EVFileSystem EVMouse EVKeyboard EVJoystick EVTouch EVGraphics EVImage EVDevTools EVCommon EVEvent EVTimer EVNetwork EVData EVSound EVAudio) +set(EVELIBS EVCmdLine EVScripts EVWindow EVFileSystem EVMouse EVKeyboard EVJoystick EVTouch EVGraphics EVImage EVDevTools EVCommon EVEvent EVTimer EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVParticles) if(EVENGINE_PLATFORM_LIB) list(APPEND EVELIBS ${EVENGINE_PLATFORM_LIB}) diff --git a/test/RenderSystem3D.cpp b/test/RenderSystem3D.cpp index 568dc4fc..35b462ea 100644 --- a/test/RenderSystem3D.cpp +++ b/test/RenderSystem3D.cpp @@ -119,6 +119,31 @@ static Texture *makeSolidGray(Graphics *gfx, uint8_t g) { return gfx->newTexture(1, 1, px); } +// Graphics/ECS are process-wide singletons — hide leftovers from earlier cases. +static void resetScene3D() { + if (ecs::ComponentManager::inst().registy != nullptr) { + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [mr] = *it; + mr->visible = false; + } + } + if (ecs::ComponentManager::inst().registy != nullptr) { + auto camView = ecs::View(); + for (auto it = camView.begin(); it != camView.end(); ++it) { + auto [data] = *it; + data->active = false; + } + } + if (ecs::ComponentManager::inst().registy != nullptr) { + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [sp] = *it; + sp->visible = false; + } + } +} + // NOTE: Graphics is a process-wide singleton — one window lifetime per process. TEST_CASE("Mesh.newMeshFromAssimpCube") { eve::window::Window *win = nullptr; @@ -155,6 +180,7 @@ TEST_CASE("RenderSystem3D.smokeRotatingCube") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); + resetScene3D(); Mesh *mesh = loadUvCube(gfx); @@ -190,6 +216,7 @@ TEST_CASE("RenderSystem3D.textureCheckerPixels") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); + resetScene3D(); Mesh *mesh = loadUvCube(gfx); auto *cam = Camera3D::createCamera(); @@ -212,8 +239,11 @@ TEST_CASE("RenderSystem3D.textureCheckerPixels") { gfx->setScreenReadbackEnabled(true); RenderSystem3D::setDirectionalLight(0.f, 0.f, 1.f, 1.f, 1.f, 1.f); - RenderSystem3D::render(*gfx); - RenderSystem::render(*gfx); + // Warm up a couple of frames after possible surface/swapchain recreate. + for (int i = 0; i < 3; ++i) { + RenderSystem3D::render(*gfx); + RenderSystem::render(*gfx); + } const int cx = gfx->getWidth() / 2; const int cy = gfx->getHeight() / 2; @@ -230,6 +260,7 @@ TEST_CASE("RenderSystem3D.lightingAffectsPixels") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); + resetScene3D(); Mesh *mesh = loadUvCube(gfx); auto *cam = Camera3D::createCamera(); @@ -252,15 +283,19 @@ TEST_CASE("RenderSystem3D.lightingAffectsPixels") { const int cy = gfx->getHeight() / 2; gfx->setScreenReadbackEnabled(true); - // LightDir is dotted with normals as-is; for +Z-facing geometry, -Z lights the front. + // Empirically (and matching the mesh/view setup): -Z lights the visible face brighter. RenderSystem3D::setDirectionalLight(0.f, 0.f, -1.f, 1.f, 1.f, 1.f); - RenderSystem3D::render(*gfx); - RenderSystem::render(*gfx); + for (int i = 0; i < 3; ++i) { + RenderSystem3D::render(*gfx); + RenderSystem::render(*gfx); + } const float L_bright = luma(gfx->getPixel(cx, cy)); RenderSystem3D::setDirectionalLight(0.f, 0.f, 1.f, 1.f, 1.f, 1.f); - RenderSystem3D::render(*gfx); - RenderSystem::render(*gfx); + for (int i = 0; i < 3; ++i) { + RenderSystem3D::render(*gfx); + RenderSystem::render(*gfx); + } const float L_dark = luma(gfx->getPixel(cx, cy)); REQUIRE(L_bright - L_dark > 0.1f); diff --git a/test/UIBuilder.cpp b/test/UIBuilder.cpp new file mode 100644 index 00000000..97033452 --- /dev/null +++ b/test/UIBuilder.cpp @@ -0,0 +1,99 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "ui/UI.h" +#include "ui/UIHost.h" +#include "ui/UISystem.h" +#include "ui/Widget.h" + +#include + +using namespace eve::ui; + +/** Game-facing panel: subclass UIHost so View still finds it. */ +class HudPanel : public UIHost { +public: + ENTITY(HudPanel, UIHost) + void release() override {} + + struct Hp { + int value = 100; + }; + COMPONENT(Hp, hp) +}; + +TEST_CASE("UI.ecs.namedHostsAndSelect") { + UI *ui = UI::create(); + ui->mountAs("hud", window("HUD", {text("HP", "hp"), button("Pause", "pause")})); + ui->mountAs("menu", window("Menu", {button("Quit", "quit")})); + + CHECK(UISystem::findHost("hud") != nullptr); + CHECK(UISystem::findHost("menu") != nullptr); + CHECK(ui->select("hud")); + CHECK(ui->current() == UISystem::findHost("hud")); + ui->setText("hp", "HP 80"); + CHECK(ui->current()->findById("hp")->text == "HP 80"); + + CHECK(ui->select("menu")); + CHECK(ui->current()->findById("quit") != nullptr); +} + +TEST_CASE("UI.ecs.subclassVisibleInView") { + HudPanel *hud = HudPanel::create(); + hud->meta()->entity = hud; + hud->setName("playerhud"); + hud->hp()->value = 42; + hud->setTree(window("P", {text("x", "x")})); + + int found = 0; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [meta, tree] = *it; + (void)tree; + if (meta->name == "playerhud") { + ++found; + CHECK(meta->entity != nullptr); + } + } + CHECK_GE(found, 1); + CHECK_EQ(hud->hp()->value, 42); + CHECK(UISystem::findHost("playerhud") == static_cast(hud)); +} + +TEST_CASE("UI.ecs.ownerBindingAndClicks") { + UI *ui = UI::create(); + ui->mountAs("inv", window("Inv", {button("Use", "use")})); + ui->bindOwner(7); + CHECK(UISystem::findHostByOwner(7) == ui->current()); + + UIEvent ev; + ev.host = ui->current(); + ev.hostName = "inv"; + ev.nodeId = "use"; + ev.kind = "click"; + ev.handlerIndex = 0; + UISystem::pendingEvents().push_back(ev); + UISystem::dispatchEvents(); + + ui->select("inv"); + CHECK(ui->consumeClick() == "inv/use"); + CHECK(ui->consumeClick().empty()); +} + +TEST_CASE("UI.scriptStyleBuilder.mountBuildAs") { + UI *ui = UI::create(); + ui->beginBuild(); + ui->beginWindow("Shop", "root"); + ui->addText("Gold: 10", "gold"); + ui->beginGroup("row"); + ui->addButton("Buy", "buy"); + ui->addSameLine(); + ui->addButton("Sell", "sell"); + ui->end(); + ui->end(); + CHECK(ui->mountBuildAs("shop")); + CHECK(ui->select("shop")); + CHECK(ui->current()->findById("gold") != nullptr); + ui->setText("gold", "Gold: 9"); + CHECK(ui->current()->findById("gold")->text == "Gold: 9"); +} diff --git a/test/UIOverlaySmoke.cpp b/test/UIOverlaySmoke.cpp new file mode 100644 index 00000000..b803da38 --- /dev/null +++ b/test/UIOverlaySmoke.cpp @@ -0,0 +1,101 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "graphics/Graphics.h" +#include "graphics/RenderSystem.h" +#include "ui/UI.h" +#include "ui/UISystem.h" +#include "ui/Widget.h" +#include "window/Window.h" + +#include + +#include + +using namespace eve::graphics; +using namespace eve::ui; + +/** + * Real window + Vulkan: world sprites (RenderSystem) and ImGui UI overlay in one present. + * Frame order: UI NewFrame/widgets → RenderSystem (draws + present with overlay) → dispatch. + */ +TEST_CASE("UI.smoke.RenderSystemSameFrameVulkanOverlay") { + auto *win = eve::window::Window::create(); + auto *gfx = Graphics::create(); + auto *ui = UI::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + REQUIRE(ui != nullptr); + + win->setGraphics(gfx); + eve::window::WindowSettings s; + s.width = 640; + s.height = 480; + s.centered = true; + REQUIRE(win->setWindowSettings(s)); + REQUIRE_GT(gfx->getWidth(), 0); + REQUIRE_GT(gfx->getPixelWidth(), 0); + + REQUIRE(ui->initBackend()); + CHECK(ui->isBackendReady()); + const bool overlayHooked = gfx->getPresentOverlay() != nullptr && gfx->getPresentOverlayUser() != nullptr; + CHECK(overlayHooked); + + gfx->setBackgroundColor(Color(0.10f, 0.12f, 0.18f, 1.0f)); + gfx->setScreenReadbackEnabled(true); + + auto *cam = Camera2D::createCamera(); + cam->data()->r = 0.10f; + cam->data()->g = 0.12f; + cam->data()->b = 0.18f; + + auto *solid = Renderable2D::create(); + solid->transform()->x = 80; + solid->transform()->y = 100; + solid->sprite()->width = 160; + solid->sprite()->height = 100; + solid->sprite()->r = 0.2f; + solid->sprite()->g = 0.85f; + solid->sprite()->b = 0.35f; + solid->sprite()->visible = true; + + ui->mountAs("smoke", + window("Overlay", + { + text("UI+RenderSystem", "label"), + button("Ping", "ping"), + }, + "root")); + + int framesOk = 0; + for (int frame = 0; frame < 30; ++frame) { + solid->transform()->x = 80.0f + float(frame) * 2.0f; + + // ImGui frame must be built before present (RenderSystem ends with present). + ui->beginFrameAndRender(); + RenderSystem::render(*gfx); + ui->dispatchEvents(); + + SDL_Event e; + while (SDL_PollEvent(&e)) { + ui->processEvent(&e); + if (e.type == SDL_QUIT) break; + } + + // After present+readback, swapchain content should be readable. + Color p = gfx->getPixel(gfx->getPixelWidth() / 2, gfx->getPixelHeight() / 2); + CHECK(std::isfinite(p.r)); + CHECK(std::isfinite(p.g)); + CHECK(std::isfinite(p.b)); + ++framesOk; + + SDL_Delay(8); + } + + CHECK_EQ(framesOk, 30); + CHECK(UISystem::findHost("smoke") != nullptr); + CHECK(ui->current()->findById("label") != nullptr); + + gfx->setScreenReadbackEnabled(false); + win->close(); +} diff --git a/test/UIPhaseAdv.cpp b/test/UIPhaseAdv.cpp new file mode 100644 index 00000000..942d31c8 --- /dev/null +++ b/test/UIPhaseAdv.cpp @@ -0,0 +1,122 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "ScriptTest.h" +#include "ui/UI.h" +#include "ui/UIHost.h" +#include "ui/UISystem.h" +#include "ui/Widget.h" + +#include +#include + +namespace ui = eve::ui; + +TEST_CASE("UI.adv.sliderProgressInputCollapseChild") { + float slid = 0.f; + std::string typed; + ui::UIHost *h = ui::UIHost::createHost("adv"); + h->setTree(ui::window( + "Adv", + { + ui::slider("Vol", 0.3f, 0.f, 1.f, "vol", [&](float v) { slid = v; }), + ui::progress(0.5f, "hp", "50%"), + ui::inputText("Name", "hero", "name", [&](const std::string &s) { typed = s; }), + ui::collapsingHeader("More", {ui::text("hidden tip", "tip")}, "more", true), + ui::child("scroll", {ui::text("row0", "r0"), ui::text("row1", "r1")}, 200.f, 80.f), + }, + "root")); + + CHECK(h->findById("vol") != nullptr); + CHECK_EQ(int(h->findById("vol")->type), int(ui::NodeType::Slider)); + CHECK(std::abs(h->findById("vol")->value - 0.3f) < 1e-5f); + CHECK_EQ(int(h->findById("hp")->type), int(ui::NodeType::Progress)); + CHECK(h->findById("name")->valueText == "hero"); + CHECK(h->findById("more") != nullptr); + CHECK(h->findById("tip") != nullptr); + CHECK(h->findById("scroll") != nullptr); + CHECK(h->findById("r1") != nullptr); + + ui::UIEvent ev; + ev.host = h; + ev.hostName = "adv"; + ev.nodeId = "vol"; + ev.kind = "value"; + ev.handlerIndex = h->findById("vol")->handlerValue; + ev.floatValue = 0.75f; + h->findById("vol")->value = 0.75f; + ui::UISystem::pendingEvents().push_back(ev); + ui::UISystem::dispatchEvents(); + CHECK(std::abs(slid - 0.75f) < 1e-5f); + CHECK(ui::UISystem::consumeChange() == "adv/vol"); +} + +TEST_CASE("UI.adv.builderScriptWidgets") { + ui::UI *uimod = ui::UI::create(); + uimod->beginBuild(); + uimod->beginWindow("Shop", "root"); + uimod->addSlider("Price", 10.f, 0.f, 100.f, "price"); + uimod->addProgress(0.2f, "load", "20%"); + uimod->addInputText("SKU", "A1", "sku"); + uimod->beginCollapsing("Details", "det", true); + uimod->addText("More info", "info"); + uimod->end(); + uimod->beginChild("list", 0.f, 60.f); + uimod->addText("item", "it"); + uimod->end(); + uimod->end(); + CHECK(uimod->mountBuildAs("shop")); + CHECK(uimod->current()->findById("price") != nullptr); + CHECK(uimod->current()->findById("sku")->valueText == "A1"); + CHECK(uimod->current()->findById("info") != nullptr); + uimod->setValue("price", 42.f); + CHECK(std::abs(uimod->getValue("price") - 42.f) < 1e-5f); + uimod->setValueText("sku", "B2"); + CHECK(uimod->getValueText("sku") == "B2"); + uimod->setHostModal(true); + CHECK(uimod->current()->meta()->modal); +} + +TEST_CASE("UI.adv.wantCaptureAPI") { + ui::UI *uimod = ui::UI::create(); + // Callable regardless of prior smoke tests that may have initialized ImGui. + bool mouse = uimod->wantCaptureMouse(); + bool keyboard = uimod->wantCaptureKeyboard(); + CHECK(mouse == mouse); + CHECK(keyboard == keyboard); +} + +static const char *kScriptComponentContent = R"SQ( +function testScriptComponent() { + local u = eve.UI() + class ShopPanel extends eve.UIComponent { + gold = 10 + constructor(uiRef) { + base.constructor(uiRef) + gold = 10 + } + function build() { + local uu = ui() + uu.beginWindow("Shop", "root") + uu.text("Gold " + gold, "gold") + uu.button("Buy", "buy") + if (gold > 5) uu.text("Rich", "rich") + uu.end() + } + } + local panel = ShopPanel(u) + panel.mountAs("shop_panel") + if (!u.select("shop_panel")) return false + panel.gold = 3 + panel.setState() + if (!panel.updateIfDirty()) return false + if (eve.Component != eve.UIComponent) return false + return true +} +)SQ"; + +UnitSciptTest(UIScriptComponentTest, kScriptComponentContent); + +TEST_CASE_FIXTURE(UIScriptComponentTest, "UI.adv.scriptUIComponent") { + CHECK(vm.callFunc(vm.findFunc("testScriptComponent"), vm).toBool()); +} diff --git a/test/UIPhaseB.cpp b/test/UIPhaseB.cpp new file mode 100644 index 00000000..1c99107e --- /dev/null +++ b/test/UIPhaseB.cpp @@ -0,0 +1,110 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "ui/Component.h" +#include "ui/Theme.h" +#include "ui/UI.h" +#include "ui/UIHost.h" +#include "ui/UISystem.h" +#include "ui/Widget.h" + +#include +#include + +using namespace eve::ui; + +class InventoryPanel : public Component { +public: + std::vector items{"Sword", "Potion"}; + int gold = 10; + bool showExtra = true; + + WidgetDesc build() override { + return window("Inventory", + { + text("Gold " + std::to_string(gold), "gold"), + separator("sep"), + listButtons("items", items), + when(showExtra, text("Extra tip", "tip")), + checkbox("Show tip", showExtra, "tipchk", [this](bool v) { + showExtra = v; + setState(); + }), + }, + "root"); + } +}; + +TEST_CASE("UI.b.listAndWhen") { + auto tree = window("W", { + when(false, text("hidden", "h")), + when(true, text("shown", "s")), + listButtons("L", {"a", "b", "c"}), + }); + UIHost *h = UIHost::createHost("listwhen"); + h->setTree(std::move(tree)); + CHECK(h->findById("s") != nullptr); + CHECK(h->findById("h") == nullptr); // when(false) → empty group, no "h" + CHECK(h->findById("L/0") != nullptr); + CHECK(h->findById("L/2") != nullptr); +} + +TEST_CASE("UI.b.keyReconcilePropsOnly") { + UIHost *h = UIHost::createHost("rec"); + h->setTree(window("W", {text("v1", "label"), button("Go", "btn")}, "root")); + bool rebuilt = h->setTreeReconcile(window("W", {text("v2", "label"), button("Go", "btn")}, "root")); + CHECK(!rebuilt); // structure same → props patch + CHECK(h->findById("label")->text == "v2"); + + rebuilt = h->setTreeReconcile( + window("W", {text("v3", "label"), button("Go", "btn"), button("New", "n")}, "root")); + CHECK(rebuilt); // child count changed → full rebuild + CHECK(h->findById("n") != nullptr); +} + +TEST_CASE("UI.b.componentRebuild") { + InventoryPanel panel; + panel.mountAs("inv"); + UIHost *h = UISystem::findHost("inv"); + REQUIRE(h != nullptr); + CHECK(h->findById("gold")->text == "Gold 10"); + CHECK(h->findById("items/0") != nullptr); + + panel.gold = 9; + panel.items.push_back("Shield"); + panel.markDirty(); + CHECK(panel.updateIfDirty()); + CHECK(h->findById("gold")->text == "Gold 9"); + CHECK(h->findById("items/2") != nullptr); +} + +TEST_CASE("UI.b.themeAndCheckbox") { + UI *ui = UI::create(); + ui->setThemeLight(); + CHECK(globalTheme().windowBg[0] > 0.5f); + ui->setThemeDark(); + ui->setNavKeyboard(true); + CHECK(globalTheme().navEnableKeyboard); + + ui->mountAs("chk", window("C", {checkbox("Mute", false, "mute")}, "root")); + CHECK(ui->current()->findById("mute") != nullptr); + CHECK(!ui->current()->findById("mute")->checked); + ui->setChecked("mute", true); + CHECK(ui->current()->findById("mute")->checked); +} + +TEST_CASE("UI.b.scriptListBuilder") { + UI *ui = UI::create(); + ui->beginBuild(); + ui->beginWindow("Shop", "root"); + ui->beginList("goods"); + ui->addListItem("Apple", "goods/0"); + ui->addListItem("Bread", "goods/1"); + ui->end(); + ui->addSeparator("s"); + ui->addCheckbox("Member", false, "mem"); + ui->end(); + CHECK(ui->mountBuildAs("shop")); + CHECK(ui->current()->findById("goods/1") != nullptr); + CHECK(ui->current()->findById("mem") != nullptr); +} diff --git a/test/UISystem.cpp b/test/UISystem.cpp new file mode 100644 index 00000000..7572f447 --- /dev/null +++ b/test/UISystem.cpp @@ -0,0 +1,100 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "ui/UIHost.h" +#include "ui/UISystem.h" +#include "ui/Widget.h" + +#include + +using namespace eve::ui; + +TEST_CASE("WidgetDesc.composeNestedTree") { + int clicks = 0; + UIHost *h = UIHost::createHost("nest"); + h->setTree(window( + "Inventory", + { + text("Hello", "label"), + group({ + button("A", "a"), + sameLine(), + button("B", "b", [&]() { ++clicks; }), + }), + }, + "root")); + + auto t = h->tree(); + REQUIRE_EQ(t->root, 0); + CHECK_EQ(int(t->nodes[0].type), int(NodeType::Window)); + REQUIRE_GE(t->nodes.size(), 5u); + + UINode *b = h->findById("b"); + REQUIRE(b != nullptr); + REQUIRE_NE(b->handlerClick, 0u); + + UIEvent ev; + ev.host = h; + ev.hostName = "nest"; + ev.nodeId = "b"; + ev.kind = "click"; + ev.handlerIndex = b->handlerClick; + UISystem::pendingEvents().push_back(ev); + UISystem::dispatchEvents(); + CHECK_EQ(clicks, 1); + CHECK(UISystem::consumeClickFor("nest") == "b"); +} + +TEST_CASE("UIHost.setSimplePanel.buildsRetainedTree") { + UIHost *h = UIHost::createHost("simple"); + h->setSimplePanel("Inventory", "Hello", "Use"); + auto t = h->tree(); + REQUIRE_EQ(t->root, 0); + REQUIRE_EQ(t->nodes.size(), 3u); + CHECK(t->nodes[1].id == "label"); + CHECK(t->nodes[2].id == "btn"); +} + +TEST_CASE("UIHost.setPropsAndClickHandler") { + UIHost *h = UIHost::createHost("props"); + h->setTree(window("W", {text("before", "label"), button("Go", "btn")})); + h->setTextById("label", "after"); + CHECK(h->findById("label")->text == "after"); + h->setVisibleById("btn", false); + CHECK(!h->findById("btn")->visible); + + int clicks = 0; + CHECK(h->setClickHandler("btn", [&]() { ++clicks; })); + + UIEvent ev; + ev.host = h; + ev.hostName = "props"; + ev.nodeId = "btn"; + ev.kind = "click"; + ev.handlerIndex = h->findById("btn")->handlerClick; + UISystem::pendingEvents().push_back(ev); + UISystem::dispatchEvents(); + CHECK_EQ(clicks, 1); +} + +TEST_CASE("UISystem.render.headlessImGuiWalk") { + UIHost *h = UIHost::createHost("walk"); + h->setTree(window("Panel", {text("Label", "label"), button("Btn", "btn")})); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.DisplaySize = ImVec2(800.f, 600.f); + io.IniFilename = nullptr; + unsigned char *pixels = nullptr; + int w = 0, htex = 0; + io.Fonts->GetTexDataAsRGBA32(&pixels, &w, &htex); + (void)pixels; + + ImGui::NewFrame(); + UISystem::render(); + ImGui::EndFrame(); + ImGui::DestroyContext(); + + CHECK(!h->tree()->dirty); +} diff --git a/test/filesystem_cpp.cpp b/test/filesystem_cpp.cpp index efdc42e7..e09e6e55 100644 --- a/test/filesystem_cpp.cpp +++ b/test/filesystem_cpp.cpp @@ -6,9 +6,12 @@ #include "filesystem/File.h" #include "filesystem/FileData.h" +#include #include +#include #include #include +#include #include namespace { @@ -217,3 +220,61 @@ TEST_CASE("filesystem.File.bufferAndFlush") { CHECK(file->close()); f->remove(name); } + +TEST_CASE("filesystem.watch.fileModified") { + useIdentity("ev_ut_fs_watch"); + auto* f = fs(); + f->unwatchAll(); + + const char* name = "ut_watch.txt"; + f->write(name, "v1", 2); + REQUIRE(f->watch(name)); + CHECK_EQ(f->getWatchCount(), 1); + + // Darwin DirectoryWatcher baselines via periodic scan; wait one interval. + std::this_thread::sleep_for(std::chrono::milliseconds(1200)); + while (!f->pollWatch().empty()) { + } + + // Prefer OS write through the resolved save path so notifications fire reliably. + std::string real = f->getSaveDirectory() + "/" + name; + { + std::ofstream out(real, std::ios::binary | std::ios::trunc); + REQUIRE(out.good()); + out.write("v2-changed", 10); + out.flush(); + } + + bool saw = false; + for (int i = 0; i < 60 && !saw; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + for (;;) { + std::string kind = f->pollWatch(); + if (kind.empty()) break; + if (kind == "modified" || kind == "added") { + CHECK_EQ(f->getLastWatchPath(), std::string(name)); + saw = true; + } + } + } + CHECK(saw); + + CHECK(f->unwatch(name)); + CHECK_EQ(f->getWatchCount(), 0); + f->remove(name); +} + +TEST_CASE("filesystem.watch.unwatchAll") { + useIdentity("ev_ut_fs_watch_all"); + auto* f = fs(); + f->unwatchAll(); + f->write("a.txt", "a", 1); + f->write("b.txt", "b", 1); + CHECK(f->watch("a.txt")); + CHECK(f->watch("b.txt")); + CHECK_GE(f->getWatchCount(), 2); + f->unwatchAll(); + CHECK_EQ(f->getWatchCount(), 0); + f->remove("a.txt"); + f->remove("b.txt"); +} diff --git a/test/particles.cpp b/test/particles.cpp new file mode 100644 index 00000000..30bbf058 --- /dev/null +++ b/test/particles.cpp @@ -0,0 +1,236 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "particles/Particles.h" +#include "particles/ParticleEmitter.h" +#include "particles/ParticleSystem.h" +#include "filesystem/Filesystem.h" + +#include +#include +#include + +using namespace eve::particles; + +TEST_CASE("particles.newEmitter.ecsView") { + auto *mod = Particles::create(); + int before = mod->getEmitterCount(); + ParticleEmitter *e = mod->newEmitter(64); + REQUIRE(e != nullptr); + CHECK_EQ(e->getBufferSize(), 64); + CHECK_EQ(e->getCount(), 0); + CHECK_EQ(mod->getEmitterCount(), before + 1); + CHECK(e->config()->entity == e); +} + +TEST_CASE("particles.emitter.emitAndLifetime") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(32); + e->setParticleLifetime(1.f, 1.f); + e->setSpeed(0.f, 0.f); + e->emit(10); + CHECK_EQ(e->getCount(), 10); + + ParticleSimSystem::update(0.5f); + CHECK_EQ(e->getCount(), 10); + + ParticleSimSystem::update(0.6f); + CHECK_EQ(e->getCount(), 0); +} + +TEST_CASE("particles.emitter.emissionRate") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(100); + e->setParticleLifetime(10.f, 10.f); + e->setEmissionRate(50.f); + e->setSpeed(0.f, 0.f); + e->start(); + ParticleSimSystem::update(0.2f); // ~10 particles + CHECK_GE(e->getCount(), 9); + CHECK_LE(e->getCount(), 11); +} + +TEST_CASE("particles.emitter.startStopPause") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(16); + e->setEmissionRate(100.f); + e->setParticleLifetime(5.f, 5.f); + CHECK(e->isStopped()); + + e->start(); + CHECK(e->isActive()); + e->pause(); + CHECK(e->isPaused()); + CHECK(!e->isActive()); + int c0 = e->getCount(); + ParticleSimSystem::update(1.f); + CHECK_EQ(e->getCount(), c0); + + e->start(); + e->stop(); + CHECK(e->isStopped()); +} + +TEST_CASE("particles.emitter.motion") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(8); + e->setPosition(0.f, 0.f); + e->setParticleLifetime(2.f, 2.f); + e->setDirection(0.f); + e->setSpread(0.f); + e->setSpeed(100.f, 100.f); + e->setLinearAcceleration(0.f, 0.f, 0.f, 0.f); + e->emit(1); + ParticleSimSystem::update(0.1f); + CHECK_EQ(e->getCount(), 1); +} + +TEST_CASE("particles.emitter.applyPreset") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(64); + e->applyPreset("spark"); + CHECK_GT(e->getEmissionRate(), 0.f); + e->applyPreset("smoke"); + CHECK_GT(e->getEmissionRate(), 0.f); + e->applyPreset("fire"); + CHECK_GT(e->getEmissionRate(), 0.f); + e->applyPreset("unknown"); + CHECK_GT(e->getEmissionRate(), 0.f); +} + +TEST_CASE("particles.system.updateAll") { + auto *mod = Particles::create(); + ParticleEmitter *a = mod->newEmitter(16); + ParticleEmitter *b = mod->newEmitter(16); + a->setParticleLifetime(1.f, 1.f); + b->setParticleLifetime(1.f, 1.f); + a->emit(3); + b->emit(5); + ParticleSimSystem::update(2.f); + CHECK_EQ(a->getCount(), 0); + CHECK_EQ(b->getCount(), 0); +} + +TEST_CASE("particles.module.updateForwardsToSystem") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(16); + e->setParticleLifetime(1.f, 1.f); + e->emit(4); + mod->update(2.f); + CHECK_EQ(e->getCount(), 0); +} + +TEST_CASE("particles.emitter.reset") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(16); + e->setParticleLifetime(5.f, 5.f); + e->emit(8); + CHECK_EQ(e->getCount(), 8); + e->reset(); + CHECK_EQ(e->getCount(), 0); +} + +TEST_CASE("particles.emitter.bufferCap") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(4); + e->setParticleLifetime(10.f, 10.f); + e->emit(100); + CHECK_EQ(e->getCount(), 4); +} + +TEST_CASE("particles.renderSystem.skipsInvisible") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(8); + e->setParticleLifetime(5.f, 5.f); + e->emit(3); + e->setVisible(false); + // No Graphics — render should early-out safely. + ParticleRenderSystem::render(nullptr); + CHECK_EQ(e->getCount(), 3); +} + +TEST_CASE("particles.config.applyJsonText") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(32); + const char *json = R"({ + "preset": "spark", + "x": 12.5, + "y": 34, + "emissionRate": 40, + "particleLifetime": [0.5, 1.5], + "speed": [10, 20], + "colorStart": [1, 0, 0, 1], + "colorEnd": [0, 0, 1, 0], + "layer": 3, + "autoStart": true + })"; + CHECK(e->applyConfig(json)); + CHECK(std::abs(e->getX() - 12.5f) < 1e-4f); + CHECK(std::abs(e->getY() - 34.f) < 1e-4f); + CHECK(std::abs(e->getEmissionRate() - 40.f) < 1e-4f); + CHECK(std::abs(e->getParticleLifetimeMin() - 0.5f) < 1e-4f); + CHECK(std::abs(e->getParticleLifetimeMax() - 1.5f) < 1e-4f); + CHECK_EQ(e->getLayer(), 3); + CHECK(e->isActive()); +} + +TEST_CASE("particles.config.applyInvalid") { + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(8); + CHECK(!e->applyConfig("{bad")); + CHECK(!e->applyConfig("[1,2,3]")); +} + +TEST_CASE("particles.config.loadFileAndHotReload") { + auto *fs = eve::filesystem::Filesystem::create(); + REQUIRE(fs->setIdentity("ev_ut_particles_cfg", true)); + REQUIRE(fs->setupWriteDirectory()); + + const char *name = "fx_spark.json"; + const char *json1 = R"({"emissionRate":11,"x":1,"y":2,"particleLifetime":[1,1]})"; + fs->write(name, json1, std::strlen(json1)); + + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitter(16); + CHECK(e->loadConfig(name)); + CHECK_EQ(e->getConfigPath(), std::string(name)); + CHECK(std::abs(e->getEmissionRate() - 11.f) < 1e-4f); + CHECK(e->getAutoReload()); + + // Force stale modtime so poll reloads without waiting on FS resolution. + e->resource()->modtime = 0; + const char *json2 = R"({"emissionRate":77,"x":9,"y":8,"particleLifetime":[2,2]})"; + fs->write(name, json2, std::strlen(json2)); + + CHECK_EQ(ParticleConfigSystem::poll(), 1); + CHECK(std::abs(e->getEmissionRate() - 77.f) < 1e-4f); + CHECK(std::abs(e->getX() - 9.f) < 1e-4f); + + e->setAutoReload(false); + e->resource()->modtime = 0; + const char *json3 = R"({"emissionRate":1})"; + fs->write(name, json3, std::strlen(json3)); + CHECK_EQ(ParticleConfigSystem::poll(), 0); + CHECK(std::abs(e->getEmissionRate() - 77.f) < 1e-4f); + + CHECK(e->reloadConfig()); + CHECK(std::abs(e->getEmissionRate() - 1.f) < 1e-4f); +} + +TEST_CASE("particles.config.newEmitterFromFile") { + auto *fs = eve::filesystem::Filesystem::create(); + REQUIRE(fs->setIdentity("ev_ut_particles_fromfile", true)); + REQUIRE(fs->setupWriteDirectory()); + + const char *name = "fx_fromfile.json"; + const char *json = R"({"buffer":48,"preset":"smoke","emissionRate":25,"autoStart":true})"; + fs->write(name, json, std::strlen(json)); + + auto *mod = Particles::create(); + ParticleEmitter *e = mod->newEmitterFromFile(name); + REQUIRE(e != nullptr); + CHECK_EQ(e->getBufferSize(), 48); + CHECK(std::abs(e->getEmissionRate() - 25.f) < 1e-4f); + CHECK(e->isActive()); + CHECK_EQ(e->getConfigPath(), std::string(name)); +}