diff --git a/client/Makefile b/client/Makefile index f4daa13..d3ca372 100644 --- a/client/Makefile +++ b/client/Makefile @@ -5,11 +5,11 @@ LDFLAGS = -lfreetype -lz -lglfw -lvulkan -ldl -Xlinker -rpath -Xlinker /opt/home CFLAGS += $(shell pkg-config --cflags lua) LDFLAGS += $(shell pkg-config --libs lua) -ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp +ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/events.c src/camera.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp APP_SOURCES = src/main.c $(ENGINE_SOURCES) -EDITOR_SOURCES = src/editor_main.c src/editor.c $(ENGINE_SOURCES) +EDITOR_SOURCES = src/editor_main.c src/editor.c src/editor_lua.c $(ENGINE_SOURCES) TEST_SOURCES = test/hsv.c $(ENGINE_SOURCES) -TEST_EDITOR_SOURCES = test/editor.c src/editor.c $(ENGINE_SOURCES) +TEST_EDITOR_SOURCES = test/editor.c src/editor.c src/editor_lua.c $(ENGINE_SOURCES) APP_OBJECTS = $(addsuffix .o, $(basename $(APP_SOURCES))) EDITOR_OBJECTS = $(addsuffix .o, $(basename $(EDITOR_SOURCES))) diff --git a/client/include/camera.h b/client/include/camera.h new file mode 100644 index 0000000..c2f9ad0 --- /dev/null +++ b/client/include/camera.h @@ -0,0 +1,78 @@ +#ifndef CAMERA_H +#define CAMERA_H + +#include +#include "gpu.h" + +// Forward declaration only (no #include "ui.h") - camera.h and ui.h are +// mutually referential (Container owns a Camera*, Camera targets a +// UIContext's texture pool) and neither needs more than a pointer to the +// other's type. +typedef struct UIContextStruct UIContext; + +// GPU mirror of the proj/view a camera pushes to its render target's push +// constant; std430 layout must match shader/camera_common.glsl's Camera +// buffer_reference struct exactly. +typedef struct GPUCameraStruct { + mat4 proj; + mat4 view; +} GPUCamera; + +// Orbital camera: looks at `position` from `distance` away, oriented by +// `rotation` (yaw, pitch). `view`/`proj` are derived state, stale until +// camera_update_view/camera_update_proj run - callers are responsible for +// calling them after changing position/rotation/distance, or after the +// camera's target size changes. +// +// A camera's on-screen placement isn't camera state - attach it to a +// Container (container_set_camera, ui.h) and that container's anchor/ +// offset/size/z-order (container_order) becomes the camera's viewport and +// draw order, same as every other container. A camera can separately (or +// instead) render into an offscreen texture via camera_init_texture_target, +// for other pipelines/containers to sample. +typedef struct CameraStruct { + vec3 position; + vec2 rotation; + double distance; + mat4 view; + mat4 proj; + + bool has_texture_target; + struct { + uint32_t width, height; + // Double-buffered per frame-in-flight: two separate vkQueueSubmits + // aren't hazard-free against each other without extra sync, so this + // mirrors the existing pattern (HexContext/Container GPU buffers) + // rather than adding a new semaphore. + uint32_t texture_slot[MAX_FRAMES_IN_FLIGHT]; + VkImage depth_image[MAX_FRAMES_IN_FLIGHT]; + VkImageView depth_image_view[MAX_FRAMES_IN_FLIGHT]; + VmaAllocation depth_image_memory[MAX_FRAMES_IN_FLIGHT]; + } texture; + + VkBuffer gpu_buffer[MAX_FRAMES_IN_FLIGHT]; + VmaAllocation gpu_buffer_memory[MAX_FRAMES_IN_FLIGHT]; + VkDeviceAddress gpu_address[MAX_FRAMES_IN_FLIGHT]; +} Camera; + +VkResult create_camera(RenderContext* gpu, Camera* camera); + +void camera_update_view(Camera* camera); + +void camera_update_proj(Camera* camera, float aspect); + +VkResult camera_sync_gpu(Camera* camera, RenderContext* gpu); + +VkResult camera_init_texture_target( + RenderContext* gpu, + UIContext* ui, + Camera* camera, + uint32_t width, + uint32_t height); + +void camera_destroy_texture_target( + RenderContext* gpu, + UIContext* ui, + Camera* camera); + +#endif diff --git a/client/include/draw.h b/client/include/draw.h index c915e63..097a1f6 100644 --- a/client/include/draw.h +++ b/client/include/draw.h @@ -9,6 +9,7 @@ VkResult draw_frame( RenderContext* context, UIContext* ui, HexContext* hex, + Camera* offscreen_camera, double time); #endif diff --git a/client/include/editor.h b/client/include/editor.h index 5ec7ba2..9ab2e83 100644 --- a/client/include/editor.h +++ b/client/include/editor.h @@ -25,32 +25,52 @@ struct ModeKeyStruct { }; struct EditorDataStruct { - EditorMode mode; - ModeKey* mode_keys[MODE_MAX_ENUM]; uint32_t mode_key_counts[MODE_MAX_ENUM]; + // The full-window container hosting context->camera (see editor_startup/ + // container_set_camera) - kept around so editor_frame_callback's resize + // check can update its size without a lookup every frame. + Container* main_container; + uint32_t selected_max; uint32_t selected_count; uint32_t* selected_regions; uint32_t* selected_hexes; uint32_t* selected_vertices; + + bool hover_valid; + uint32_t hover_region; + uint32_t hover_hex; + uint32_t hover_vertex; + + // Camera control scheme: WASD/scroll accumulate into these, which + // editor_frame_callback integrates into context->camera each frame. The + // camera itself (position/rotation/distance/view) is engine-owned + // (ClientContext.camera) - this is app policy for how input drives it, + // not camera state, so a different app (or a scripted sequence) can drive + // the same camera without this accumulator scheme at all. + vec3 velocity; + int32_t spin[2]; + int32_t zoom; + + float spin_speed; + float zoom_speed; + float move_speed; }; EditorData* create_editor_data(void); void editor_startup(ClientContext* context); -void editor_frame_callback(ClientContext* context); +void editor_frame_callback(ClientContext* context, double delta_time); void editor_key_callback(ClientContext* context, int key, int action, int mods); void editor_scroll_callback(ClientContext* context, double x, double y); void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods); +void editor_cursor_callback(ClientContext* context, float x, float y); -void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); -void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); -void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); -void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); -void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); +EditorMode current_mode(ClientContext* context); +void editor_set_mode(ClientContext* context, EditorMode mode); void resize_selected(EditorData* data, unsigned int size); int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex); diff --git a/client/include/editor_lua.h b/client/include/editor_lua.h new file mode 100644 index 0000000..0facc04 --- /dev/null +++ b/client/include/editor_lua.h @@ -0,0 +1,10 @@ +#ifndef EDITOR_LUA_H +#define EDITOR_LUA_H + +#include "engine.h" + +// Registers the `camera` global table (editor-specific, needs ClientContext, +// so it can't live in the engine-generic ui_lua.c) +void editor_lua_register(lua_State* L, ClientContext* context); + +#endif diff --git a/client/include/engine.h b/client/include/engine.h index cbef20c..5f16f14 100644 --- a/client/include/engine.h +++ b/client/include/engine.h @@ -5,6 +5,8 @@ #include "gpu.h" #include "ui.h" #include "hex.h" +#include "events.h" +#include "camera.h" typedef struct ClientContextStruct ClientContext; @@ -37,7 +39,8 @@ typedef void (*app_cursor_callback)( float y); typedef void (*app_frame_function)( - ClientContext* context); + ClientContext* context, + double delta_time); typedef void (*app_startup_function)( ClientContext* context); @@ -48,19 +51,14 @@ struct ClientContextStruct { RenderContext render; UIContext ui; HexContext hex; - - vec3 position; - vec3 velocity; - - vec2 rotation; - int32_t spin[2]; - - double distance; - int32_t zoom; - - float spin_speed; - float zoom_speed; - float move_speed; + EventBus events; + Camera camera; + + // Nullable, non-owning. Set by the app to render a second camera's scene + // into an offscreen texture each frame (see camera_init_texture_target, + // camera.h) - NULL (the zero-init default) skips that pass entirely, so + // apps that don't opt in see no behavior change. + Camera* offscreen_camera; void* app_data; app_frame_function app_frame; diff --git a/client/include/events.h b/client/include/events.h new file mode 100644 index 0000000..5fcd119 --- /dev/null +++ b/client/include/events.h @@ -0,0 +1,126 @@ +#ifndef EVENTS_H +#define EVENTS_H + +#include "ui.h" + +// Source id for events/writes originating from C. Container ids are nonzero +// (load_container rejects 0), so 0 is free to mean "the engine". +#define EVENT_SOURCE_ENGINE 0 + +// Names under "engine." can only be emitted by C; app.emit rejects them. +// Property writes announce themselves as "engine.changed.". +#define EVENT_ENGINE_PREFIX "engine." +#define EVENT_CHANGED_PREFIX "engine.changed." + +// Dispatched directly (never queued) once per drain after the queue is +// exhausted, so handlers reliably run after every change event of the +// frame — the coalescing point for dirty-flag subscribers. Events emitted +// by its handlers queue for the next frame. +#define EVENT_FRAME "engine.frame" + +// Max events processed per drain; hitting it means a handler cycle is +// endlessly re-emitting, so the remainder is dumped to stderr and dropped. +#define EVENT_DRAIN_MAX 1024 + +typedef enum PropertyTypeEnum { + PROPERTY_NUMBER, + PROPERTY_STRING, + PROPERTY_BOOL, +} PropertyType; + +typedef struct PropertyStruct { + char* name; + PropertyType type; + union { + double number; + char* string; // owned; NULL until first set + bool boolean; + } value; +} Property; + +// C subscriber. The event's packed args table is on the Lua stack at +// args_index (t[1..t.n]); it must still be there when the handler returns. +typedef void (*EventHandler)( + void* userdata, + const char* event, + uint32_t source, + lua_State* L, + int args_index); + +typedef struct EventSubscriptionStruct { + char* event; + // Registry ref of the script this subscription was made from, restored as + // the ambient "current script" before invoking a Lua handler — so + // ui.create_overlay called from inside it attributes correctly. Not used + // for lifecycle: subscriptions currently live for the process, since + // nothing unloads a script (only overlays, which are a separate + // lifecycle — see events-design memory). + int script_env; + int lua_ref; // handler function ref, LUA_NOREF for C handlers + EventHandler handler; // NULL for Lua handlers + void* userdata; + // Removal during a drain marks instead of compacting so in-flight + // iteration stays valid; compaction happens when the drain finishes + uint8_t dead; +} EventSubscription; + +typedef struct QueuedEventStruct { + char* name; + uint32_t source; + int args_ref; // registry ref to the packed args table +} QueuedEvent; + +struct EventBusStruct { + lua_State* L; + + EventSubscription* subs; + uint32_t sub_count; + uint32_t sub_cap; + + QueuedEvent* queue; + uint32_t queue_count; + uint32_t queue_cap; + + Property* properties; + uint32_t property_count; + uint32_t property_cap; + + bool draining; +}; + +// Registers the `app` global (emit/subscribe/get/set) in the Lua state +VkResult event_bus_init(EventBus* bus, lua_State* L); + +// Pops nargs values off the top of the Lua stack into the event's packed +// args and appends to the queue. Never dispatches; the queue drains once +// per frame from the top level. +VkResult event_emit(EventBus* bus, const char* name, uint32_t source, int nargs); + +// Subscribes a C handler; Lua handlers subscribe via app.subscribe +VkResult event_subscribe(EventBus* bus, const char* name, EventHandler handler, void* userdata); + +// Dispatches queued events in emit order, including events emitted by +// handlers during the drain, up to EVENT_DRAIN_MAX +void event_bus_drain(EventBus* bus, UIContext* ui, RenderContext* gpu, double delta_time); + +VkResult event_property_register(EventBus* bus, const char* name, PropertyType type); + +// Direct read access; NULL if unregistered +Property* event_property(EventBus* bus, const char* name); + +// Setters no-op when the value is unchanged; otherwise they store the value +// and emit engine.changed. with the new value as the single argument. +// Emission is unconditional on real changes regardless of who wrote. +VkResult event_property_set_number(EventBus* bus, const char* name, double value, uint32_t source); +VkResult event_property_set_string(EventBus* bus, const char* name, const char* value, uint32_t source); +VkResult event_property_set_bool(EventBus* bus, const char* name, bool value, uint32_t source); + +// Arg accessors for C EventHandler bodies +int event_arg_count(lua_State* L, int args_index); +double event_arg_number(lua_State* L, int args_index, int i); +bool event_arg_bool(lua_State* L, int args_index, int i); +// Returned pointer is anchored by the args table; valid until the drain +// releases the event +const char* event_arg_string(lua_State* L, int args_index, int i); + +#endif diff --git a/client/include/gpu.h b/client/include/gpu.h index 409a3a2..210e9ce 100644 --- a/client/include/gpu.h +++ b/client/include/gpu.h @@ -257,4 +257,15 @@ VkShaderModule load_shader_file( VkResult recreate_framebuffer( RenderContext* gpu); +VkResult create_depth_image( + VkDevice device, + VkFormat depth_format, + VkExtent2D extent, + VmaAllocator allocator, + VkCommandPool extra_graphics_pool, + GPUQueue graphics_queue, + VkImage* depth_image, + VmaAllocation* depth_image_memory, + VkImageView* depth_image_view); + #endif diff --git a/client/include/hex.h b/client/include/hex.h index b65c05d..1788f29 100644 --- a/client/include/hex.h +++ b/client/include/hex.h @@ -2,11 +2,13 @@ #define HEX_H #include "gpu.h" +#include "camera.h" #include "vulkan/vulkan_core.h" #define MAX_RAYS 10 -#define MAX_HIGHLIGHTS 10 -#define MAX_POINTS 10 +// Last slot of each is reserved for the hover preview (see editor.c). +#define MAX_HIGHLIGHTS 11 +#define MAX_POINTS 11 #define MAX_LOADED_REGIONS 2500 #define REGION_SIZE 10 @@ -77,8 +79,6 @@ typedef struct GPUPointStruct { } GPUPoint; typedef struct GPUHexContextStruct { - mat4 proj; - mat4 view; uint32_t current_map; VkDeviceAddress rays; VkDeviceAddress points; @@ -115,6 +115,7 @@ typedef struct HexContextStruct { typedef struct HexPushConstantStruct { VkDeviceAddress context; + VkDeviceAddress camera; double time; } HexPushConstant; @@ -156,15 +157,13 @@ bool ray_world_intersect( bool edge_only, HexContext* context); -VkResult update_hex_proj( - RenderContext* gpu, - HexContext* hex); - -VkResult update_hex_view( - vec3 position, - vec2 rotation, - double distance, - RenderContext* gpu, +// Recomputes the picking-ray inverse matrix (hex->inverse) from a camera's +// already-computed proj/view (see camera_update_proj/camera_update_view). +// Picking is only ever done against one camera at a time (the interactive +// one - see cursor_to_world_ray's full-window assumption), so callers pick +// which camera's proj*view this reflects; it isn't tied to any specific one. +void update_hex_picking_inverse( + Camera* camera, HexContext* hex); void hex_qr(uint32_t hex, HexCoord* world); diff --git a/client/include/ui.h b/client/include/ui.h index e05c590..fa72ba3 100644 --- a/client/include/ui.h +++ b/client/include/ui.h @@ -130,6 +130,10 @@ typedef struct StringResourcesStruct { typedef struct UIContextStruct UIContext; typedef struct ContainerStruct Container; +typedef struct EventBusStruct EventBus; +// Forward declaration only (no #include "camera.h") - see camera.h's +// matching note on why these two headers don't include each other. +typedef struct CameraStruct Camera; struct ContainerStruct { VkBuffer container[MAX_FRAMES_IN_FLIGHT]; @@ -178,8 +182,21 @@ struct ContainerStruct { uint32_t id; - int script_env; // Lua registry ref for this container's environment table, 0 = no script - char* script_path; + // Non-owning, nullable. When set, this container's region (anchor/offset/ + // size) and slot in container_order double as the camera's viewport and + // draw order - see container_set_camera/container_screen_rect. + Camera* camera; + + // script_env: which script's dispatch handlers (on_button etc.) run for + // this container's input, 0 = no script. Set by ui.create_overlay, not by + // load_container — creation and script execution are decoupled; a single + // script_env can own multiple containers. + int script_env; + // Cached ref to this container's overlay handle userdata, so every + // dispatch call (and re-lookup from Lua) hands back the same object — + // required for scripts to compare handles by identity (overlay == + // some_local). LUA_NOREF until ui.create_overlay makes one. + int overlay_ref; }; typedef struct ContainerInputStruct { @@ -187,8 +204,6 @@ typedef struct ContainerInputStruct { uint32_t anchor; vec2 offset; vec2 size; - - const char* script_path; // path to the .lua file declaring the container's elements } ContainerInput; typedef struct GPUUIContextStruct { @@ -228,6 +243,10 @@ struct UIContextStruct { uint32_t max_containers; Container* containers; + // Auto-incrementing id source for ui.create_overlay; starts at 1 so 0 + // stays reserved for EVENT_SOURCE_ENGINE / "no container". Never reused, + // so a stale overlay handle's id can never collide with a live container. + uint32_t next_container_id; // Container slot indices in draw order (back to front). Loading appends, // so new containers spawn on top. @@ -240,6 +259,10 @@ struct UIContextStruct { lua_State* lua; + // Set by the app after event_bus_init; unload_container uses it to drop + // the container's event subscriptions + EventBus* events; + Container* active_container; uint32_t active_element; @@ -267,11 +290,25 @@ VkResult load_texture( UIContext* context, uint32_t* index); +// Registers a color-attachment-capable texture (COLOR_ATTACHMENT_BIT | +// SAMPLED_BIT, gpu->swapchain_format.format) in the same bindless slot pool +// load_texture uses, for a camera to render into and other pipelines to +// later sample - see camera_init_texture_target (camera.h). +VkResult create_render_target_texture( + RenderContext* gpu, + UIContext* context, + uint32_t width, + uint32_t height, + uint32_t* index); + VkResult load_container( ContainerInput* container, RenderContext* gpu, UIContext* context); +// Allocates the next auto-incrementing container id for ui.create_overlay +uint32_t ui_alloc_container_id(UIContext* context); + VkResult unload_container( uint32_t id, RenderContext* gpu, @@ -279,6 +316,16 @@ VkResult unload_container( void ui_container_to_front(uint32_t id, UIContext* ui); +// Non-owning: caller keeps camera alive as long as it's attached. Pass NULL +// to detach (the container goes back to being a plain 2D overlay). +void container_set_camera(Container* container, Camera* camera); + +// Converts a container's anchored region (point space, like anchor_offset) +// to a physical-pixel VkViewport/VkRect2D-compatible rect (window_scale +// space, like swapchain_extent) - the conversion a screen-region camera's +// viewport needs that anchor_offset alone doesn't provide. +void container_screen_rect(RenderContext* gpu, Container* container, VkRect2D* out); + // Runtime element management. Slots are stable for the element's lifetime; // destroyed slots are recycled by later creates. VkResult ui_create_drawable( diff --git a/client/include/ui_lua.h b/client/include/ui_lua.h index 2c84430..8bb4e7d 100644 --- a/client/include/ui_lua.h +++ b/client/include/ui_lua.h @@ -3,27 +3,49 @@ #include "ui.h" -// Registers the `ui` global table and input constants in the Lua state +// Registers the `ui`/`app` globals and input constants in the Lua state void ui_lua_register(lua_State* L); -// Loads a script into a sandboxed environment, stores the env ref in c->script_env -VkResult ui_lua_load_container( +// Loads and runs a script file in its own sandboxed environment. Creation +// and script execution are decoupled: this makes no container. The script +// calls ui.create_overlay itself, from wherever it wants one — top level, +// a dispatch handler, or a bus subscription callback, any of which may run +// after this call returns, since app.subscribe can defer indefinitely. +VkResult ui_lua_run_script( lua_State* L, - Container* c, UIContext* ui, RenderContext* gpu, const char* path); -// Calls a global function in the container's script with one string argument -VkResult ui_lua_call( - UIContext* ui, - RenderContext* gpu, +// The container an input dispatch is currently calling into; NULL outside +// dispatch (top-level script execution, bus subscription callbacks) — those +// contexts have no single "current" overlay, so ui.rect/ui.text require one +// to have been created first. +Container* ui_lua_current_container(lua_State* L); + +// Points the binding's registry context at a container (or NULL) before +// calling into a script from outside the normal dispatch path +void ui_lua_set_current( + lua_State* L, Container* c, - const char* function, - const char* argument); + UIContext* ui, + RenderContext* gpu); + +// The script_env of whichever script is conceptually "running" right now — +// the script executing its top level, the script owning the container an +// input dispatch is calling into, or the script that registered the bus +// subscription currently being delivered. Used to attribute new overlays +// (ui.create_overlay) and new subscriptions (app.subscribe) to the right +// script. LUA_NOREF if nothing is running (shouldn't happen in practice). +int ui_lua_current_script(lua_State* L); + +void ui_lua_set_current_script(lua_State* L, int script_env); // Called by the engine input path. Each returns true if the container's script -// defined the handler and it returned a truthy value (event consumed). +// defined the handler and it returned a truthy value (event consumed). The +// handler receives the dispatching container's overlay handle as its first +// argument, before element: a script's script_env may back more than one +// container, so a shared on_button etc. needs to know which one fired. bool ui_lua_dispatch_button( UIContext* ui, RenderContext* gpu, diff --git a/client/script/color_picker.lua b/client/script/color_picker.lua deleted file mode 100644 index 37183d5..0000000 --- a/client/script/color_picker.lua +++ /dev/null @@ -1,265 +0,0 @@ --- Color picker: element declarations and interaction logic. --- z controls stacking within the container; equal z means don't overlap. - -local bg = ui.rect{ - pos = {0, 0}, - size = {190, 150}, - color = {0.4, 0.4, 0.4, 0.8}, - z = 0, -} - -local sv_square = ui.rect{ - type = RECT_HSV, - pos = {2, 2}, - size = {130, 130}, - colors = {{0, 0, 1, 1}, {0, 1, 1, 1}, {0, 0, 0, 1}, {0, 1, 0, 1}}, - events = EVENT_BUTTON, - z = 1, -} - -local hue_bar = ui.rect{ - type = RECT_HSV, - pos = {134, 2}, - size = {10, 130}, - colors = {{0, 1, 1, 1}, {0, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}}, - events = EVENT_BUTTON + EVENT_SCROLL, - z = 1, -} - -local sv_outline = ui.rect{ - pos = {130-4, 130-4}, - size = {7, 7}, - color = {0, 0, 0, 1}, - z = 2, -} - -local sv_select = ui.rect{ - type = RECT_HSV, - pos = {130-3, 130-3}, - size = {5, 5}, - color = {1, 0, 0, 1}, - z = 3, -} - -local hex_area = ui.rect{ - pos = {20, 134}, - size = {95, 15}, - events = EVENT_BUTTON + EVENT_CURSOR, - z = 1, -} - -local hue_select = ui.rect{ - pos = {134, 2}, - size = {10, 1}, - color = {0, 0, 0, 1}, - z = 2, -} - -local hex_text = ui.text{ - pos = {2, 150}, - size = 16, - color = {1, 1, 1, 1}, - font = 0, - max_length = 16, - z = 2, -} - --- 12 saved color slots in a 2-wide grid -local slots = {} -local slot_index = {} -for i = 1, 12 do - local slot = ui.rect{ - pos = {146 + ((i-1) % 2)*22, 2 + math.floor((i-1)/2)*22}, - size = {20, 20}, - color = {0, 0, 0, 1}, - events = EVENT_BUTTON, - z = 1, - } - slots[i] = slot - slot_index[slot] = i -end - -local state = { - hsv = {0, 0, 0}, - rgb = {0, 0, 0, 1}, - saved = {}, - hex_string = "#000000FF", -} -for i = 1, 12 do state.saved[i] = {0, 0, 0, 0} end - -local function clamp01(x) return math.max(0, math.min(1, x)) end - -local function update_hex_string() - state.hex_string = string.format("#%02X%02X%02X%02X", - math.floor(state.rgb[1]*255 + 0.5), - math.floor(state.rgb[2]*255 + 0.5), - math.floor(state.rgb[3]*255 + 0.5), - math.floor(state.rgb[4]*255 + 0.5)) - hex_text:set_text(state.hex_string) -end - -local function sync_rgb_from_hsv() - state.rgb[1], state.rgb[2], state.rgb[3] = - ui.hsv_to_rgb(state.hsv[1], state.hsv[2], state.hsv[3]) - update_hex_string() -end - -local function sv_pick(s, v) - s = clamp01(s) - v = clamp01(v) - state.hsv[2] = s - state.hsv[3] = v - - sv_select:set_pos(s*130 - 2, 130 - v*130 - 2) - for corner = 0, 3 do - sv_select:set_corner_color(corner, state.hsv[1], s, v, 1) - end - sv_outline:set_pos(s*130 - 3, 130 - v*130 - 3) - - sync_rgb_from_hsv() -end - -local function hue_set(h) - h = clamp01(h) - state.hsv[1] = h - - for _, element in ipairs({sv_square, sv_select}) do - for corner = 0, 3 do - local _, s, v, a = element:corner_color(corner) - element:set_corner_color(corner, h, s, v, a) - end - end - hue_select:set_pos(134, 2 + h*130) - - sync_rgb_from_hsv() -end - -local function hex_string_highlight(value) - for corner = 0, 3 do - local r, g, _, _ = hex_area:corner_color(corner) - hex_area:set_corner_color(corner, r, g, value, value) - end -end - -local function set_saved(index, color) - state.saved[index] = {color[1], color[2], color[3], color[4]} - local slot = slots[index] - for corner = 0, 3 do - local _, _, _, a = slot:corner_color(corner) - slot:set_corner_color(corner, color[1], color[2], color[3], a) - end -end - --- Sync HSV+visuals from state.rgb; used after setting state.rgb directly. -local function apply_state_rgb() - state.hsv[1], state.hsv[2], state.hsv[3] = - ui.rgb_to_hsv(state.rgb[1], state.rgb[2], state.rgb[3]) - hue_set(state.hsv[1]) - sv_pick(state.hsv[2], state.hsv[3]) -end - -local drag = { - [sv_square] = function(x, y) sv_pick(x, 1 - y) end, - [hue_bar] = function(x, y) hue_set(y) end, -} - -function on_button(element, x, y, button, action, mods) - if drag[element] then - if action == PRESS and button == MOUSE_LEFT then - element:focus() - drag[element](x, y) - elseif action == RELEASE and button == MOUSE_LEFT then - ui.blur() - end - return true - - elseif element == hex_area then - if action == PRESS and button == MOUSE_LEFT then - element:focus() - hex_string_highlight(1) - end - return true - - elseif slot_index[element] then - local index = slot_index[element] - if action == PRESS then - if button == MOUSE_LEFT then - local saved = state.saved[index] - state.rgb = {saved[1], saved[2], saved[3], saved[4]} - apply_state_rgb() - elseif button == MOUSE_RIGHT then - set_saved(index, state.rgb) - elseif button == MOUSE_MIDDLE then - set_saved(index, {0, 0, 0, 0}) - end - end - return true - end - - return false -end - -function on_cursor(element, x, y) - for el, fn in pairs(drag) do - if el:is_focused() then - fn(x, y) - return true - end - end - return false -end - -function on_scroll(element, x, y) - if element == hue_bar then - hue_set(state.hsv[1] + y*0.01) - return true - end - return false -end - -function on_key(element, key, action, mods) - if element ~= hex_area then return false end - - if action == PRESS then - if key == KEY_ESCAPE then - sync_rgb_from_hsv() - ui.blur() - elseif key == KEY_ENTER then - local s = state.hex_string - for i = 1, 4 do - state.rgb[i] = (tonumber(s:sub(2*i, 2*i + 1), 16) or 0) / 255 - end - apply_state_rgb() - ui.blur() - elseif key == KEY_BACKSPACE then - if #state.hex_string > 1 then - state.hex_string = state.hex_string:sub(1, -2) - hex_text:set_text(state.hex_string) - end - end - end - - return true -end - -function on_text(element, codepoint) - if element ~= hex_area then return false end - - if codepoint < 128 then - local ch = string.char(codepoint):upper() - if ch:match("[0-9A-F]") and #state.hex_string < 9 then - state.hex_string = state.hex_string .. ch - hex_text:set_text(state.hex_string) - end - end - return true -end - -function on_deselect(element) - if element == hex_area then - hex_string_highlight(0) - end -end - --- runs once at container load -hex_text:set_text(state.hex_string) diff --git a/client/script/editor_mode.lua b/client/script/editor_mode.lua new file mode 100644 index 0000000..fbc4d35 --- /dev/null +++ b/client/script/editor_mode.lua @@ -0,0 +1,17 @@ +-- Mode-switch input handling: turns V/N/H/R/Escape presses into writes of +-- the editor.mode property. Separate from editor_ui.lua, which only reacts +-- to that property and doesn't care what triggers it. + +local key_to_mode = { + [KEY_V] = "Vertex", + [KEY_N] = "Neighbor", + [KEY_H] = "Hex", + [KEY_R] = "Region", + [KEY_ESCAPE] = "None", +} + +app.subscribe("engine.key", function(source, key, action, mods) + if action ~= PRESS then return end + local mode = key_to_mode[key] + if mode then app.set("editor.mode", mode) end +end) diff --git a/client/script/editor_ui.lua b/client/script/editor_ui.lua new file mode 100644 index 0000000..1878616 --- /dev/null +++ b/client/script/editor_ui.lua @@ -0,0 +1,337 @@ +-- Editor UI controller. Loaded once at startup; owns the mode label +-- (always present) and the color picker (created/destroyed with the mode), +-- both driven by the editor.mode property. z controls stacking within an +-- overlay; equal z means don't overlap. + +-- ============================== mode label ============================== + +local label_overlay = ui.create_overlay{anchor = ANCHOR_TOP_LEFT, size = {160, 40}} +local label = ui.text{ + pos = {0, 32}, + size = 32, + color = {1, 1, 1, 1}, + font = 0, + max_length = 8, + z = 0, +} +label:set_text(app.get("editor.mode") or "None") + +-- ============================== color picker ============================= +-- Elements/state are outer locals, assigned (not re-declared) each time +-- open_picker() runs, so the shared on_button etc. below always see +-- whichever picker is currently open. + +local picker_overlay = nil +local bg, sv_square, hue_bar, sv_outline, sv_select +local hex_area, hue_select, hex_text, slots, slot_index, drag +local state + +local function clamp01(x) return math.max(0, math.min(1, x)) end + +local function update_hex_string() + state.hex_string = string.format("#%02X%02X%02X%02X", + math.floor(state.rgb[1]*255 + 0.5), + math.floor(state.rgb[2]*255 + 0.5), + math.floor(state.rgb[3]*255 + 0.5), + math.floor(state.rgb[4]*255 + 0.5)) + hex_text:set_text(state.hex_string) + -- Publish for C and other overlays; only ever called with a complete + -- color, so editor.color never holds a half-typed hex string + app.set("editor.color", state.hex_string) +end + +local function sync_rgb_from_hsv() + state.rgb[1], state.rgb[2], state.rgb[3] = + ui.hsv_to_rgb(state.hsv[1], state.hsv[2], state.hsv[3]) + update_hex_string() +end + +local function sv_pick(s, v) + s = clamp01(s) + v = clamp01(v) + state.hsv[2] = s + state.hsv[3] = v + + sv_select:set_pos(s*130 - 2, 130 - v*130 - 2) + for corner = 0, 3 do + sv_select:set_corner_color(corner, state.hsv[1], s, v, 1) + end + sv_outline:set_pos(s*130 - 3, 130 - v*130 - 3) + + sync_rgb_from_hsv() +end + +local function hue_set(h) + h = clamp01(h) + state.hsv[1] = h + + for _, element in ipairs({sv_square, sv_select}) do + for corner = 0, 3 do + local _, s, v, a = element:corner_color(corner) + element:set_corner_color(corner, h, s, v, a) + end + end + hue_select:set_pos(134, 2 + h*130) + + sync_rgb_from_hsv() +end + +local function hex_string_highlight(value) + for corner = 0, 3 do + local r, g, _, _ = hex_area:corner_color(corner) + hex_area:set_corner_color(corner, r, g, value, value) + end +end + +local function set_saved(index, color) + state.saved[index] = {color[1], color[2], color[3], color[4]} + local slot = slots[index] + for corner = 0, 3 do + local _, _, _, a = slot:corner_color(corner) + slot:set_corner_color(corner, color[1], color[2], color[3], a) + end +end + +-- Sync HSV+visuals from state.rgb; used after setting state.rgb directly. +local function apply_state_rgb() + state.hsv[1], state.hsv[2], state.hsv[3] = + ui.rgb_to_hsv(state.rgb[1], state.rgb[2], state.rgb[3]) + hue_set(state.hsv[1]) + sv_pick(state.hsv[2], state.hsv[3]) +end + +local function open_picker() + if picker_overlay then return end + picker_overlay = ui.create_overlay{anchor = ANCHOR_BOTTOM_LEFT, size = {190, 150}} + + bg = ui.rect{ + pos = {0, 0}, + size = {190, 150}, + color = {0.4, 0.4, 0.4, 0.8}, + z = 0, + } + + sv_square = ui.rect{ + type = RECT_HSV, + pos = {2, 2}, + size = {130, 130}, + colors = {{0, 0, 1, 1}, {0, 1, 1, 1}, {0, 0, 0, 1}, {0, 1, 0, 1}}, + events = EVENT_BUTTON, + z = 1, + } + + hue_bar = ui.rect{ + type = RECT_HSV, + pos = {134, 2}, + size = {10, 130}, + colors = {{0, 1, 1, 1}, {0, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}}, + events = EVENT_BUTTON + EVENT_SCROLL, + z = 1, + } + + sv_outline = ui.rect{ + pos = {130-4, 130-4}, + size = {7, 7}, + color = {0, 0, 0, 1}, + z = 2, + } + + sv_select = ui.rect{ + type = RECT_HSV, + pos = {130-3, 130-3}, + size = {5, 5}, + color = {1, 0, 0, 1}, + z = 3, + } + + hex_area = ui.rect{ + pos = {20, 134}, + size = {95, 15}, + events = EVENT_BUTTON + EVENT_CURSOR, + z = 1, + } + + hue_select = ui.rect{ + pos = {134, 2}, + size = {10, 1}, + color = {0, 0, 0, 1}, + z = 2, + } + + hex_text = ui.text{ + pos = {2, 150}, + size = 16, + color = {1, 1, 1, 1}, + font = 0, + max_length = 16, + z = 2, + } + + -- 12 saved color slots in a 2-wide grid + slots = {} + slot_index = {} + for i = 1, 12 do + local slot = ui.rect{ + pos = {146 + ((i-1) % 2)*22, 2 + math.floor((i-1)/2)*22}, + size = {20, 20}, + color = {0, 0, 0, 1}, + events = EVENT_BUTTON, + z = 1, + } + slots[i] = slot + slot_index[slot] = i + end + + state = { + hsv = {0, 0, 0}, + rgb = {0, 0, 0, 1}, + saved = {}, + hex_string = "#000000FF", + } + for i = 1, 12 do state.saved[i] = {0, 0, 0, 0} end + + drag = { + [sv_square] = function(x, y) sv_pick(x, 1 - y) end, + [hue_bar] = function(x, y) hue_set(y) end, + } + + -- adopt the current published color so reopening the picker doesn't + -- reset the selection + local published = app.get("editor.color") + if published then + for i = 1, 4 do + state.rgb[i] = (tonumber(published:sub(2*i, 2*i + 1), 16) or 0) / 255 + end + end + apply_state_rgb() +end + +local function close_picker() + if not picker_overlay then return end + picker_overlay:destroy() + picker_overlay = nil + bg, sv_square, hue_bar, sv_outline, sv_select = nil, nil, nil, nil, nil + hex_area, hue_select, hex_text, slots, slot_index, drag = nil, nil, nil, nil, nil, nil + state = nil +end + +-- ============================== dispatch ================================ +-- label_overlay has no handlers; every handler below only ever fires for +-- picker_overlay, but still checks since a shared script_env would call the +-- same on_button etc. for any future overlay this script owns. + +function on_button(overlay, element, x, y, button, action, mods) + if overlay ~= picker_overlay then return false end + + if drag[element] then + if action == PRESS and button == MOUSE_LEFT then + element:focus() + drag[element](x, y) + elseif action == RELEASE and button == MOUSE_LEFT then + ui.blur() + end + return true + + elseif element == hex_area then + if action == PRESS and button == MOUSE_LEFT then + element:focus() + hex_string_highlight(1) + end + return true + + elseif slot_index[element] then + local index = slot_index[element] + if action == PRESS then + if button == MOUSE_LEFT then + local saved = state.saved[index] + state.rgb = {saved[1], saved[2], saved[3], saved[4]} + apply_state_rgb() + elseif button == MOUSE_RIGHT then + set_saved(index, state.rgb) + elseif button == MOUSE_MIDDLE then + set_saved(index, {0, 0, 0, 0}) + end + end + return true + end + + return false +end + +function on_cursor(overlay, element, x, y) + if overlay ~= picker_overlay then return false end + + for el, fn in pairs(drag) do + if el:is_focused() then + fn(x, y) + return true + end + end + return false +end + +function on_scroll(overlay, element, x, y) + if overlay ~= picker_overlay then return false end + + if element == hue_bar then + hue_set(state.hsv[1] + y*0.01) + return true + end + return false +end + +function on_key(overlay, element, key, action, mods) + if overlay ~= picker_overlay or element ~= hex_area then return false end + + if action == PRESS then + if key == KEY_ESCAPE then + sync_rgb_from_hsv() + ui.blur() + elseif key == KEY_ENTER then + local s = state.hex_string + for i = 1, 4 do + state.rgb[i] = (tonumber(s:sub(2*i, 2*i + 1), 16) or 0) / 255 + end + apply_state_rgb() + ui.blur() + elseif key == KEY_BACKSPACE then + if #state.hex_string > 1 then + state.hex_string = state.hex_string:sub(1, -2) + hex_text:set_text(state.hex_string) + end + end + end + + return true +end + +function on_text(overlay, element, codepoint) + if overlay ~= picker_overlay or element ~= hex_area then return false end + + if codepoint < 128 then + local ch = string.char(codepoint):upper() + if ch:match("[0-9A-F]") and #state.hex_string < 9 then + state.hex_string = state.hex_string .. ch + hex_text:set_text(state.hex_string) + end + end + return true +end + +function on_deselect(overlay, element) + if overlay ~= picker_overlay then return end + if element == hex_area then + hex_string_highlight(0) + end +end + +-- ============================== mode tracking ============================ + +app.subscribe("engine.changed.editor.mode", function(source, mode) + label:set_text(mode) + if mode == "Vertex" or mode == "Neighbor" or mode == "Hex" then + open_picker() + else + close_picker() + end +end) diff --git a/client/script/mode_string.lua b/client/script/mode_string.lua deleted file mode 100644 index 72e337a..0000000 --- a/client/script/mode_string.lua +++ /dev/null @@ -1,16 +0,0 @@ --- Mode indicator label; the editor calls set_mode() on mode changes - -local label = ui.text{ - pos = {0, 32}, - size = 32, - color = {1, 1, 1, 1}, - font = 0, - max_length = 8, - z = 0, -} - -function set_mode(name) - label:set_text(name) -end - -set_mode("None") diff --git a/client/shader/camera_common.glsl b/client/shader/camera_common.glsl new file mode 100644 index 0000000..95dc2d2 --- /dev/null +++ b/client/shader/camera_common.glsl @@ -0,0 +1,4 @@ +layout(std430, buffer_reference) readonly buffer Camera { + mat4 proj; + mat4 view; +}; diff --git a/client/shader/hex.vert b/client/shader/hex.vert index 740594a..08c7d8e 100644 --- a/client/shader/hex.vert +++ b/client/shader/hex.vert @@ -55,5 +55,5 @@ void main() { } color = int2color(region.hexes[hex_index].colors[indices[gl_VertexIndex]]); - gl_Position = pc.context.proj * pc.context.view * position; + gl_Position = pc.camera.proj * pc.camera.view * position; } diff --git a/client/shader/hex_common.glsl b/client/shader/hex_common.glsl index d467f88..9918571 100644 --- a/client/shader/hex_common.glsl +++ b/client/shader/hex_common.glsl @@ -1,3 +1,5 @@ +#include "camera_common.glsl" + struct Hex { float heights[6]; uint colors[7]; @@ -46,8 +48,6 @@ layout(std430, buffer_reference) readonly buffer RayList { }; layout(std430, buffer_reference) readonly buffer HexContext { - mat4 proj; - mat4 view; uint current_map; RayList rays; PointList points; @@ -57,6 +57,7 @@ layout(std430, buffer_reference) readonly buffer HexContext { layout(std430, push_constant) uniform PushConstant { HexContext context; + Camera camera; float time; } pc; diff --git a/client/shader/hex_highlight.vert b/client/shader/hex_highlight.vert index e33ad6c..2d2ebe8 100644 --- a/client/shader/hex_highlight.vert +++ b/client/shader/hex_highlight.vert @@ -45,7 +45,7 @@ void main() { position.y += raise; - gl_Position = pc.context.proj * pc.context.view * position; + gl_Position = pc.camera.proj * pc.camera.view * position; } else { gl_Position = vec4(0, 0, 0, 0); color = vec4(0, 0, 0, 0); diff --git a/client/shader/point.vert b/client/shader/point.vert index 208910d..1757f82 100644 --- a/client/shader/point.vert +++ b/client/shader/point.vert @@ -36,7 +36,7 @@ void main() { position.y = region.hexes[hex_index].heights[vertex_index-1] + region.y + raise; - gl_Position = pc.context.proj * pc.context.view * position; + gl_Position = pc.camera.proj * pc.camera.view * position; } else { gl_Position = vec4(0, 0, 0, 0); color = vec4(0, 0, 0, 0); diff --git a/client/shader/ray.vert b/client/shader/ray.vert index 19bce5c..f1fce92 100644 --- a/client/shader/ray.vert +++ b/client/shader/ray.vert @@ -7,9 +7,9 @@ layout(location = 0) flat out vec4 color; void main() { if(gl_VertexIndex == 0) { - gl_Position = pc.context.proj * pc.context.view * pc.context.rays.r[gl_InstanceIndex].start; + gl_Position = pc.camera.proj * pc.camera.view * pc.context.rays.r[gl_InstanceIndex].start; } else { - gl_Position = pc.context.proj * pc.context.view * pc.context.rays.r[gl_InstanceIndex].end; + gl_Position = pc.camera.proj * pc.camera.view * pc.context.rays.r[gl_InstanceIndex].end; } color = pc.context.rays.r[gl_InstanceIndex].color; } diff --git a/client/src/camera.c b/client/src/camera.c new file mode 100644 index 0000000..f7bc7fb --- /dev/null +++ b/client/src/camera.c @@ -0,0 +1,85 @@ +#include "camera.h" +#include "ui.h" + +#include +#include + +static vec3 up = {0, 1, 0}; + +VkResult create_camera(RenderContext* gpu, Camera* camera) { + VkResult result; + for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { + VK_RESULT(create_storage_buffer( + gpu->allocator, + 0, + sizeof(GPUCamera), + &camera->gpu_buffer[i], + &camera->gpu_buffer_memory[i])); + camera->gpu_address[i] = buffer_address(gpu->device, camera->gpu_buffer[i]); + } + return VK_SUCCESS; +} + +void camera_update_view(Camera* camera) { + vec3 eye = {}; + eye[0] = camera->position[0] + camera->distance*cos(camera->rotation[1])*cos(camera->rotation[0]); + eye[1] = camera->position[1] + camera->distance*sin(camera->rotation[1]); + eye[2] = camera->position[2] + camera->distance*cos(camera->rotation[1])*sin(camera->rotation[0]); + + glm_lookat(eye, camera->position, up, camera->view); +} + +void camera_update_proj(Camera* camera, float aspect) { + glm_perspective( + PERSPECTIVE_FOVY, + aspect, + PERSPECTIVE_NEARZ, + PERSPECTIVE_FARZ, + camera->proj); +} + +VkResult camera_sync_gpu(Camera* camera, RenderContext* gpu) { + GPUCamera data; + glm_mat4_copy(camera->proj, data.proj); + glm_mat4_copy(camera->view, data.view); + return add_transfers(&data, camera->gpu_buffer, 0, sizeof(GPUCamera), gpu); +} + +VkResult camera_init_texture_target( + RenderContext* gpu, + UIContext* ui, + Camera* camera, + uint32_t width, + uint32_t height) { + VkResult result; + + camera->texture.width = width; + camera->texture.height = height; + + VkExtent2D extent = {width, height}; + for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { + VK_RESULT(create_render_target_texture(gpu, ui, width, height, &camera->texture.texture_slot[i])); + VK_RESULT(create_depth_image( + gpu->device, + gpu->depth_format, + extent, + gpu->allocator, + gpu->extra_graphics_pool, + gpu->graphics_queue, + &camera->texture.depth_image[i], + &camera->texture.depth_image_memory[i], + &camera->texture.depth_image_view[i])); + } + + camera->has_texture_target = true; + return VK_SUCCESS; +} + +void camera_destroy_texture_target(RenderContext* gpu, UIContext* ui, Camera* camera) { + (void)ui; + for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { + vkDestroyImageView(gpu->device, camera->texture.depth_image_view[i], NULL); + vmaDestroyImage(gpu->allocator, camera->texture.depth_image[i], camera->texture.depth_image_memory[i]); + } + camera->has_texture_target = false; +} diff --git a/client/src/draw.c b/client/src/draw.c index c0fa5b3..4adac63 100644 --- a/client/src/draw.c +++ b/client/src/draw.c @@ -2,27 +2,10 @@ #include "hex.h" #include "vulkan/vulkan_core.h" -void record_ui_draw(VkCommandBuffer command_buffer, UIContext* ui_context, double time, uint32_t frame) { - UIPushConstant push = { - .time = (float)time, - }; - - vkCmdBindPipeline(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.pipeline); - vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 0, 1, &ui_context->font_samplers, 0, NULL); - vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 1, 1, &ui_context->font_textures, 0, NULL); - vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 2, 1, &ui_context->samplers, 0, NULL); - vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 3, 1, &ui_context->textures, 0, NULL); - for(uint32_t o = 0; o < ui_context->container_order_count; o++) { - Container* c = &ui_context->containers[ui_context->container_order[o]]; - push.container = c->address[frame]; - vkCmdPushConstants(command_buffer, ui_context->pipeline.layout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &push); - vkCmdDrawIndirect(command_buffer, c->container[frame], offsetof(GPUContainer, draw), 1, 0); - } -} - -void record_hex_draw(VkCommandBuffer command_buffer, HexContext* hex, double time, uint32_t frame) { +void record_hex_draw(VkCommandBuffer command_buffer, HexContext* hex, VkDeviceAddress camera_address, double time, uint32_t frame) { HexPushConstant push = { .context = hex->address[frame], + .camera = camera_address, .time = (float)time, }; @@ -41,6 +24,69 @@ void record_hex_draw(VkCommandBuffer command_buffer, HexContext* hex, double tim vkCmdDraw(command_buffer, 2, 2, 0, 0); } +static void bind_ui_pipeline(VkCommandBuffer command_buffer, UIContext* ui) { + vkCmdBindPipeline(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.pipeline); + vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 0, 1, &ui->font_samplers, 0, NULL); + vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 1, 1, &ui->font_textures, 0, NULL); + vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 2, 1, &ui->samplers, 0, NULL); + vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 3, 1, &ui->textures, 0, NULL); +} + +// Single pass over container_order (back to front, same z-order UI has +// always used): a container with a camera attached renders that camera's +// hex scene scissored to its own region first, then every container +// (camera-attached or not) draws its normal 2D drawables via the UI +// pipeline on top - see draw_frame's single unified rendering scope, which +// is what makes interleaving these two pipelines per-container possible +// without a barrier between them. +void record_container_draw( + VkCommandBuffer command_buffer, + RenderContext* gpu, + UIContext* ui, + HexContext* hex, + VkViewport full_viewport, + VkRect2D full_scissor, + double time, + uint32_t frame) { + UIPushConstant push = { + .time = (float)time, + }; + + bind_ui_pipeline(command_buffer, ui); + + for(uint32_t o = 0; o < ui->container_order_count; o++) { + Container* c = &ui->containers[ui->container_order[o]]; + + if(c->camera != NULL) { + VkRect2D rect; + container_screen_rect(gpu, c, &rect); + VkViewport camera_viewport = { + .x = (float)rect.offset.x, + .y = (float)rect.offset.y, + .width = (float)rect.extent.width, + .height = (float)rect.extent.height, + .minDepth = 0.0f, + .maxDepth = 1.0f, + }; + vkCmdSetViewport(command_buffer, 0, 1, &camera_viewport); + vkCmdSetScissor(command_buffer, 0, 1, &rect); + + record_hex_draw(command_buffer, hex, c->camera->gpu_address[frame], time, frame); + + // record_hex_draw left a hex pipeline bound (a different bind point + // state) and the viewport/scissor scoped to this container - restore + // both before this (or the next) container's UI drawables. + vkCmdSetViewport(command_buffer, 0, 1, &full_viewport); + vkCmdSetScissor(command_buffer, 0, 1, &full_scissor); + bind_ui_pipeline(command_buffer, ui); + } + + push.container = c->address[frame]; + vkCmdPushConstants(command_buffer, ui->pipeline.layout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &push); + vkCmdDrawIndirect(command_buffer, c->container[frame], offsetof(GPUContainer, draw), 1, 0); + } +} + void record_ui_compute(VkCommandBuffer command_buffer, UIContext* ui, uint32_t frame) { UIPushConstant push = { .time = 0.0, @@ -61,6 +107,7 @@ VkResult draw_frame( RenderContext* context, UIContext* ui, HexContext* hex, + Camera* offscreen_camera, double time) { VkResult result; @@ -170,6 +217,95 @@ VkResult draw_frame( VK_RESULT(vkResetCommandBuffer(command_buffer, 0)); VK_RESULT(vkBeginCommandBuffer(command_buffer, &begin_info)); + VkImageSubresourceRange color_range = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = 1, + .layerCount = 1, + }; + + // Targets a different image entirely (not the swapchain), so this can run + // any time after the command buffer begins - independent of the acquire + // below. The read barrier at the end is the producer-before-consumer sync + // that makes the texture safe for the unified scene pass (or anything + // else) to sample later this same frame. + if(offscreen_camera != NULL) { + uint32_t f = context->current_frame; + Texture* target = &ui->texture_slots[offscreen_camera->texture.texture_slot[f]]; + + VkViewport offscreen_viewport = { + .width = (float)offscreen_camera->texture.width, + .height = (float)offscreen_camera->texture.height, + .maxDepth = 1.0f, + .minDepth = 0.0f, + }; + vkCmdSetViewport(command_buffer, 0, 1, &offscreen_viewport); + + VkRect2D offscreen_scissor = { + .extent = {offscreen_camera->texture.width, offscreen_camera->texture.height}, + }; + vkCmdSetScissor(command_buffer, 0, 1, &offscreen_scissor); + + VkImageMemoryBarrier offscreen_acquire_barrier = { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = target->image, + .subresourceRange = color_range, + }; + vkCmdPipelineBarrier(command_buffer, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + 0, 0, NULL, 0, NULL, 1, &offscreen_acquire_barrier); + + VkRenderingAttachmentInfo offscreen_color_attachment = { + .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, + .imageView = target->view, + .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = VK_ATTACHMENT_STORE_OP_STORE, + .clearValue = {.color = {{0.0f, 0.0f, 0.0f, 0.0f}}}, + }; + VkRenderingAttachmentInfo offscreen_depth_attachment = { + .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, + .imageView = offscreen_camera->texture.depth_image_view[f], + .imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, + .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .clearValue = {.depthStencil = {1.0f, 0}}, + }; + VkRenderingInfo offscreen_rendering = { + .sType = VK_STRUCTURE_TYPE_RENDERING_INFO, + .renderArea = {{0, 0}, offscreen_scissor.extent}, + .layerCount = 1, + .colorAttachmentCount = 1, + .pColorAttachments = &offscreen_color_attachment, + .pDepthAttachment = &offscreen_depth_attachment, + }; + vkCmdBeginRendering(command_buffer, &offscreen_rendering); + record_hex_draw(command_buffer, hex, offscreen_camera->gpu_address[f], time, f); + vkCmdEndRendering(command_buffer); + + VkImageMemoryBarrier offscreen_read_barrier = { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + .dstAccessMask = VK_ACCESS_SHADER_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = target->image, + .subresourceRange = color_range, + }; + vkCmdPipelineBarrier(command_buffer, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, NULL, 0, NULL, 1, &offscreen_read_barrier); + } + VkViewport viewport = { .width = context->swapchain_extent.width, .height = context->swapchain_extent.height, @@ -183,12 +319,6 @@ VkResult draw_frame( }; vkCmdSetScissor(command_buffer, 0, 1, &scissor); - VkImageSubresourceRange color_range = { - .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, - .levelCount = 1, - .layerCount = 1, - }; - VkImageMemoryBarrier acquire_barrier = { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .srcAccessMask = 0, @@ -221,7 +351,7 @@ VkResult draw_frame( .storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, .clearValue = {.depthStencil = {1.0f, 0}}, }; - VkRenderingInfo hex_rendering = { + VkRenderingInfo scene_rendering = { .sType = VK_STRUCTURE_TYPE_RENDERING_INFO, .renderArea = {{0, 0}, context->swapchain_extent}, .layerCount = 1, @@ -229,42 +359,8 @@ VkResult draw_frame( .pColorAttachments = &color_attachment, .pDepthAttachment = &depth_attachment, }; - vkCmdBeginRendering(command_buffer, &hex_rendering); - record_hex_draw(command_buffer, hex, time, context->current_frame); - vkCmdEndRendering(command_buffer); - - VkImageMemoryBarrier mid_barrier = { - .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, - .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, - .dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, - .oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, - .newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, - .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .image = context->swapchain_images[image_index], - .subresourceRange = color_range, - }; - vkCmdPipelineBarrier(command_buffer, - VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, - VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, - 0, 0, NULL, 0, NULL, 1, &mid_barrier); - - VkRenderingAttachmentInfo ui_color_attachment = { - .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, - .imageView = context->swapchain_image_views[image_index], - .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, - .loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, - .storeOp = VK_ATTACHMENT_STORE_OP_STORE, - }; - VkRenderingInfo ui_rendering = { - .sType = VK_STRUCTURE_TYPE_RENDERING_INFO, - .renderArea = {{0, 0}, context->swapchain_extent}, - .layerCount = 1, - .colorAttachmentCount = 1, - .pColorAttachments = &ui_color_attachment, - }; - vkCmdBeginRendering(command_buffer, &ui_rendering); - record_ui_draw(command_buffer, ui, time, context->current_frame); + vkCmdBeginRendering(command_buffer, &scene_rendering); + record_container_draw(command_buffer, context, ui, hex, viewport, scissor, time, context->current_frame); vkCmdEndRendering(command_buffer); VkImageMemoryBarrier present_barrier = { diff --git a/client/src/editor.c b/client/src/editor.c index 7cf2a21..06558d8 100644 --- a/client/src/editor.c +++ b/client/src/editor.c @@ -2,19 +2,29 @@ #include "hex.h" #include "engine.h" #include "editor.h" +#include "events.h" #include "ui_lua.h" +#include "editor_lua.h" #include "vulkan/vulkan_core.h" #include #include - -#define COLOR_PICK_CONTAINER_ID 0x02 -#define MODE_STRING_CONTAINER_ID 0x01 +#include #define SELECTION_HIGHLIGHT_OFFSET 0.05f #define SELECTION_POINT_SIZE 12.0f #define SELECTION_POINT_OFFSET 0.05f +// Hover preview uses the last (reserved) highlight/point slot, a slightly +// higher offset so it never z-fights with a selected element underneath, +// and a translucent color that switches between "would add" and "would +// remove" depending on whether the hovered element is already selected. +#define HOVER_HIGHLIGHT_OFFSET 0.07f +#define HOVER_POINT_SIZE 16.0f +#define HOVER_POINT_OFFSET 0.07f +#define HOVER_ADD_COLOR {0.2f, 0.85f, 1.0f, 0.45f} +#define HOVER_REMOVE_COLOR {1.0f, 0.2f, 0.2f, 0.55f} + const char* ModeStrings[] = { "None", "Vertex", @@ -93,58 +103,106 @@ uint32_t add_hex_region(ClientContext* context) { return i; } -VkResult color_ui(ClientContext* context) { - if(context_container(COLOR_PICK_CONTAINER_ID, &context->ui) != NULL) { - return VK_SUCCESS; +// Movement/spin keys are only ever seen by editor.c when no UI element has +// focus (engine.c routes key events to the focused element first). If focus +// is gained while a key is held, its release never reaches us and the +// accumulator in move_cam/spin_cam is left stuck. Zeroing here every frame a +// UI element is focused keeps the camera from drifting forever in that case. +// +// Integrates the editor's WASD/scroll control scheme into the engine-owned +// context->camera. The orbital parameterization itself lives on Camera; this +// is just the policy of how held input maps to changes in it, so a scripted +// sequence or a different app could drive the same camera without any of +// this accumulator machinery. +void editor_frame_callback(ClientContext* context, double delta_time) { + EditorData* data = context->app_data; + Camera* camera = &context->camera; + + // Which cameras/containers track the window size is app policy (engine.c + // only handles UI's own screen-space scale) - the main view is the one + // built-in camera, kept full-window in this pass (see + // cursor_to_world_ray's full-window assumption in hex.c). + if(context->render.framebuffer_recreated) { + data->main_container->data.size[0] = context->render.swapchain_extent.width / context->render.window_scale[0]; + data->main_container->data.size[1] = context->render.swapchain_extent.height / context->render.window_scale[1]; + add_transfers( + &data->main_container->data.size, + data->main_container->container, + offsetof(GPUContainer, size), + sizeof(vec2), + &context->render); + + camera_update_proj(camera, (float)context->render.swapchain_extent.width/(float)context->render.swapchain_extent.height); + camera_sync_gpu(camera, &context->render); + update_hex_picking_inverse(camera, &context->hex); } - ContainerInput container = { - .anchor = ANCHOR_BOTTOM_LEFT, - .id = COLOR_PICK_CONTAINER_ID, - .offset = {0, 0}, - .size = {190, 150}, - .script_path = "script/color_picker.lua", - }; + if(context->ui.active_container != NULL) { + data->velocity[0] = 0; + data->velocity[1] = 0; + data->velocity[2] = 0; + data->spin[0] = 0; + data->spin[1] = 0; + } - return load_container(&container, &context->render, &context->ui); -} + if(data->spin[0] != 0 || data->spin[1] != 0 || + data->velocity[0] != 0 || data->velocity[1] != 0 || data->velocity[2] != 0 || + data->zoom != 0) { -VkResult mode_string_ui(ClientContext* context) { - ContainerInput container = { - .id = MODE_STRING_CONTAINER_ID, - .anchor = ANCHOR_TOP_LEFT, - .size = {160, 40}, - .script_path = "script/mode_string.lua", - }; + camera->rotation[0] += (float)data->spin[0]*delta_time*data->spin_speed; + if(camera->rotation[0] > 2*M_PI) { + camera->rotation[0] -= 2*M_PI; + } else if(camera->rotation[0] < 0) { + camera->rotation[0] += 2*M_PI; + } - return load_container(&container, &context->render, &context->ui); -} + camera->rotation[1] += (float)data->spin[1]*delta_time*data->spin_speed; + if(camera->rotation[1] > (M_PI/2 - 0.1)) { + camera->rotation[1] = (M_PI/2 - 0.1); + } else if(camera->rotation[1] < 0) { + camera->rotation[1] = 0; + } -VkResult update_mode_string(ClientContext* context, EditorData* data) { - Container* container = context_container(MODE_STRING_CONTAINER_ID, &context->ui); - return ui_lua_call(&context->ui, &context->render, container, "set_mode", ModeStrings[data->mode]); -} + float move_x = data->velocity[0]; + float move_z = data->velocity[2]; + float move_mag = sqrt(move_x*move_x + move_z*move_z); + if(move_mag > 1) { + move_x /= move_mag; + move_z /= move_mag; + } -// Movement/spin keys are only ever seen by editor.c when no UI element has -// focus (engine.c routes key events to the focused element first). If focus -// is gained while a key is held, its release never reaches us and the -// accumulator in move_cam/spin_cam is left stuck. Zeroing here every frame a -// UI element is focused keeps the camera from drifting forever in that case. -void editor_frame_callback(ClientContext* context) { - if(context->ui.active_container != NULL) { - context->velocity[0] = 0; - context->velocity[1] = 0; - context->velocity[2] = 0; - context->spin[0] = 0; - context->spin[1] = 0; + camera->position[0] += - move_z*data->move_speed*cos(camera->rotation[0]) + - move_x*data->move_speed*sin(camera->rotation[0]); + + camera->position[2] += move_x*data->move_speed*cos(camera->rotation[0]) + - move_z*data->move_speed*sin(camera->rotation[0]); + + camera->position[1] += data->velocity[1]*data->move_speed; + + // data->zoom is a one-frame scroll impulse, not a held input like + // spin/velocity, so it isn't scaled by delta_time. + camera->distance += data->zoom*data->zoom_speed; + if(camera->distance < 1) { + camera->distance = 1; + } + + camera_update_view(camera); + camera_sync_gpu(camera, &context->render); + update_hex_picking_inverse(camera, &context->hex); } + + // Consumed above; reset after (not at the top, where it would wipe this + // frame's scroll_callback input, which fires during glfwPollEvents, + // before this callback runs). + data->zoom = 0; } void editor_key_callback(ClientContext* context, int key, int action, int mods) { EditorData* data = context->app_data; - for(uint32_t i = 0; i < data->mode_key_counts[data->mode]; i++) { - if(data->mode_keys[data->mode][i].key == key) { - data->mode_keys[data->mode][i].logic(data, context, &data->mode_keys[data->mode][i], action, mods); + EditorMode mode = current_mode(context); + for(uint32_t i = 0; i < data->mode_key_counts[mode]; i++) { + if(data->mode_keys[mode][i].key == key) { + data->mode_keys[mode][i].logic(data, context, &data->mode_keys[mode][i], action, mods); return; } } @@ -158,22 +216,23 @@ void editor_key_callback(ClientContext* context, int key, int action, int mods) } void spin_cam_key(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)data; + (void)context; (void)mods; - if(action == GLFW_PRESS) context->spin[binding->axis] += binding->amount; - else if(action == GLFW_RELEASE) context->spin[binding->axis] -= binding->amount; + if(action == GLFW_PRESS) data->spin[binding->axis] += binding->amount; + else if(action == GLFW_RELEASE) data->spin[binding->axis] -= binding->amount; } void move_cam_key(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)data; + (void)context; (void)mods; - if(action == GLFW_PRESS) context->velocity[binding->axis] += binding->amount; - else if(action == GLFW_RELEASE) context->velocity[binding->axis] -= binding->amount; + if(action == GLFW_PRESS) data->velocity[binding->axis] += binding->amount; + else if(action == GLFW_RELEASE) data->velocity[binding->axis] -= binding->amount; } void editor_scroll_callback(ClientContext* context, double x, double y) { (void)x; - context->zoom = (int32_t)y; + EditorData* data = context->app_data; + data->zoom = (int32_t)y; } void resize_selected(EditorData* data, unsigned int size) { @@ -249,8 +308,10 @@ bool editor_pick(ClientContext* context, double cursor[2], uint32_t* rid, uint32 return ray_world_intersect(&distance, vertex, rid, hid, start, end, edge_only, &context->hex); } +// Selection uses all highlight/point slots except the last, which is +// reserved for the hover preview (see refresh_hover_visuals). void sync_hex_highlights(EditorData* data, ClientContext* context) { - for(uint32_t i = 0; i < MAX_HIGHLIGHTS; i++) { + for(uint32_t i = 0; i < MAX_HIGHLIGHTS - 1; i++) { if(i < data->selected_count) { GPUHighlight temp = { .color = {1.0f, 0.85f, 0.05f, 1.0f}, @@ -267,7 +328,7 @@ void sync_hex_highlights(EditorData* data, ClientContext* context) { } void sync_vertex_points(EditorData* data, ClientContext* context) { - for(uint32_t i = 0; i < MAX_POINTS; i++) { + for(uint32_t i = 0; i < MAX_POINTS - 1; i++) { if(i < data->selected_count) { GPUPoint temp = { .color = {1.0f, 0.85f, 0.05f, 1.0f}, @@ -285,87 +346,115 @@ void sync_vertex_points(EditorData* data, ClientContext* context) { } } -void refresh_selection_visuals(EditorData* data, ClientContext* context) { - sync_hex_highlights(data, context); - sync_vertex_points(data, context); -} +// Writes (or disables) the reserved hover slot. Color depends on whether +// the hovered element is already selected, so a click's effect (add vs. +// remove) is visible before the user commits to it. +void refresh_hover_visuals(EditorData* data, ClientContext* context) { + EditorMode mode = current_mode(context); + uint32_t highlight_slot = MAX_HIGHLIGHTS - 1; + // In vertex mode the hex highlight is just spatial context for the + // hovered vertex, so it mirrors the vertex's own add/remove color rather + // than tracking hex selection (vertex mode doesn't select whole hexes). + if((mode == MODE_HEX || mode == MODE_VERTEX) && data->hover_valid) { + bool would_remove = (mode == MODE_HEX) + ? find_selected_hex(data, data->hover_region, data->hover_hex) != -1 + : find_selected_vertex(data, data->hover_region, data->hover_hex, data->hover_vertex) != -1; + GPUHighlight temp; + if(would_remove) temp = (GPUHighlight){.color = HOVER_REMOVE_COLOR}; + else temp = (GPUHighlight){.color = HOVER_ADD_COLOR}; + temp.region = data->hover_region; + temp.hex = data->hover_hex; + temp.offset = HOVER_HIGHLIGHT_OFFSET; + add_transfers(&temp, context->hex.highlights, sizeof(GPUHighlight)*highlight_slot, sizeof(GPUHighlight), &context->render); + } else { + uint32_t disabled = 0xFFFFFFFF; + add_transfers(&disabled, context->hex.highlights, sizeof(GPUHighlight)*highlight_slot + offsetof(GPUHighlight, hex), sizeof(uint32_t), &context->render); + } -void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)binding; - (void)mods; - if(action == GLFW_PRESS) { - data->mode = MODE_NONE; - update_mode_string(context, data); - data->selected_count = 0; - refresh_selection_visuals(data, context); - unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); + uint32_t point_slot = MAX_POINTS - 1; + if(mode == MODE_VERTEX && data->hover_valid) { + bool would_remove = find_selected_vertex(data, data->hover_region, data->hover_hex, data->hover_vertex) != -1; + GPUPoint temp; + if(would_remove) temp = (GPUPoint){.color = HOVER_REMOVE_COLOR}; + else temp = (GPUPoint){.color = HOVER_ADD_COLOR}; + temp.region = data->hover_region; + temp.hex = data->hover_hex; + temp.vertex = data->hover_vertex; + temp.size = HOVER_POINT_SIZE; + temp.offset = HOVER_POINT_OFFSET; + add_transfers(&temp, context->hex.points, sizeof(GPUPoint)*point_slot, sizeof(GPUPoint), &context->render); + } else { + uint32_t disabled = 0xFFFFFFFF; + add_transfers(&disabled, context->hex.points, sizeof(GPUPoint)*point_slot + offsetof(GPUPoint, hex), sizeof(uint32_t), &context->render); } } -void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)binding; - (void)mods; - if(action == GLFW_PRESS) { - data->mode = MODE_VERTEX; - update_mode_string(context, data); - data->selected_count = 0; - refresh_selection_visuals(data, context); - color_ui(context); - } +void refresh_selection_visuals(EditorData* data, ClientContext* context) { + sync_hex_highlights(data, context); + sync_vertex_points(data, context); + refresh_hover_visuals(data, context); } -void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)binding; - (void)mods; - if(action == GLFW_PRESS) { - data->mode = MODE_NEIGHBOR; - update_mode_string(context, data); - data->selected_count = 0; - refresh_selection_visuals(data, context); - color_ui(context); +// editor.mode is the single source of truth (a property on the event bus); +// nothing stores a separate copy. Reads are synchronous and cheap (a strcmp +// loop over MODE_MAX_ENUM short strings) so there's nothing worth caching. +EditorMode current_mode(ClientContext* context) { + const char* name = event_property(&context->events, "editor.mode")->value.string; + for(int i = 0; i < MODE_MAX_ENUM; i++) { + if(strcmp(ModeStrings[i], name) == 0) return (EditorMode)i; } + return MODE_NONE; } -void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)binding; - (void)mods; - if(action == GLFW_PRESS) { - data->mode = MODE_HEX; - update_mode_string(context, data); - data->selected_count = 0; - refresh_selection_visuals(data, context); - color_ui(context); - } +// The only C-side way to change modes; script/editor_mode.lua changes modes +// by writing the same property directly via app.set instead. Either path +// queues engine.changed.editor.mode, which on_mode_changed below reacts to +// uniformly regardless of which path triggered it. +void editor_set_mode(ClientContext* context, EditorMode mode) { + event_property_set_string(&context->events, "editor.mode", ModeStrings[mode], EVENT_SOURCE_ENGINE); } -void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { - (void)binding; - (void)mods; - if(action == GLFW_PRESS) { - data->mode = MODE_REGION; - update_mode_string(context, data); - data->selected_count = 0; - refresh_selection_visuals(data, context); - unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); - } +// Selection/hover don't carry across modes; this is the only place that +// resets them, so it runs the same whether the mode change came from C or +// from a script's app.set. +static void on_mode_changed(void* userdata, const char* event, uint32_t source, lua_State* L, int args) { + (void)event; + (void)source; + (void)L; + (void)args; + ClientContext* context = userdata; + EditorData* data = context->app_data; + data->selected_count = 0; + data->hover_valid = false; + refresh_selection_visuals(data, context); } void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods) { EditorData* data = context->app_data; + EditorMode mode = current_mode(context); if(button != GLFW_MOUSE_BUTTON_LEFT || action != GLFW_PRESS) return; - if(data->mode != MODE_VERTEX && data->mode != MODE_HEX) return; + if(mode != MODE_VERTEX && mode != MODE_HEX) return; + + uint32_t rid, hid, v; + if(data->hover_valid) { + // editor_cursor_callback already raycast this exact spot for the hover + // preview; the cursor can't have moved between that and this click, so + // reuse it instead of raycasting again. + rid = data->hover_region; + hid = data->hover_hex; + v = data->hover_vertex; + } else { + double cursor[2] = {x, y}; + uint32_t vertex; + // Vertex mode requests edge-only picking so a click always resolves to a + // real, editable vertex instead of frequently landing on the hex center + // (vertex 0) and giving no feedback. + if(!editor_pick(context, cursor, &rid, &hid, &vertex, mode == MODE_VERTEX)) return; + v = (mode == MODE_VERTEX) ? vertex : 0; + } - double cursor[2] = {x, y}; - uint32_t rid, hid, vertex; - // Vertex mode requests edge-only picking so a click always resolves to a - // real, editable vertex instead of frequently landing on the hex center - // (vertex 0) and giving no feedback. - bool hit = editor_pick(context, cursor, &rid, &hid, &vertex, data->mode == MODE_VERTEX); - if(!hit) return; - - uint32_t v = (data->mode == MODE_VERTEX) ? vertex : 0; - int32_t idx = (data->mode == MODE_VERTEX) + int32_t idx = (mode == MODE_VERTEX) ? find_selected_vertex(data, rid, hid, v) : find_selected_hex(data, rid, hid); @@ -381,8 +470,91 @@ void editor_button_callback(ClientContext* context, float x, float y, int button refresh_selection_visuals(data, context); } +void editor_cursor_callback(ClientContext* context, float x, float y) { + EditorData* data = context->app_data; + EditorMode mode = current_mode(context); + + if(mode != MODE_VERTEX && mode != MODE_HEX) { + if(data->hover_valid) { + data->hover_valid = false; + refresh_hover_visuals(data, context); + } + return; + } + + double cursor[2] = {x, y}; + uint32_t rid, hid, vertex; + bool hit = editor_pick(context, cursor, &rid, &hid, &vertex, mode == MODE_VERTEX); + uint32_t v = (mode == MODE_VERTEX) ? vertex : 0; + + // Skip the GPU write unless the hovered element actually changed; cursor + // motion fires far more often than the hover target changes. + bool changed = hit != data->hover_valid + || (hit && (data->hover_region != rid || data->hover_hex != hid + || (mode == MODE_VERTEX && data->hover_vertex != v))); + if(!changed) return; + + data->hover_valid = hit; + if(hit) { + data->hover_region = rid; + data->hover_hex = hid; + data->hover_vertex = v; + } + refresh_hover_visuals(data, context); +} + void editor_startup(ClientContext* context) { - mode_string_ui(context); + EditorData* data = context->app_data; + + editor_lua_register(context->ui.lua, context); + + // Application state surface visible to scripts; registered before any + // container loads so app.get sees the initial values + event_property_register(&context->events, "editor.mode", PROPERTY_STRING); + event_property_register(&context->events, "editor.color", PROPERTY_STRING); + event_property_set_string(&context->events, "editor.mode", ModeStrings[MODE_NONE], EVENT_SOURCE_ENGINE); + event_property_set_string(&context->events, "editor.color", "#000000FF", EVENT_SOURCE_ENGINE); + + // Registered before any script loads so it's guaranteed to see mode + // changes editor_mode.lua triggers, ahead of editor_ui.lua's own reaction + event_subscribe(&context->events, "engine.changed.editor.mode", on_mode_changed, context); + + // Controller script: owns the mode label and the color picker overlay, + // both driven entirely by the editor.mode property from here on + ui_lua_run_script(context->ui.lua, &context->ui, &context->render, "script/editor_ui.lua"); + // Input->state-machine trigger: writes editor.mode on V/N/H/R/Escape + ui_lua_run_script(context->ui.lua, &context->ui, &context->render, "script/editor_mode.lua"); + + // Main 3D view: a full-window container hosting the interactive camera, + // same as every other overlay - see container_set_camera (ui.h). Screen- + // region targeting lives on the container, not the camera, so this is + // the only place that needs to know the camera renders full-window. + ContainerInput main_view = { + .id = ui_alloc_container_id(&context->ui), + .anchor = ANCHOR_TOP_LEFT, + .offset = {0, 0}, + .size = { + context->render.swapchain_extent.width / context->render.window_scale[0], + context->render.swapchain_extent.height / context->render.window_scale[1], + }, + }; + load_container(&main_view, &context->render, &context->ui); + data->main_container = context_container(main_view.id, &context->ui); + container_set_camera(data->main_container, &context->camera); + + // The editor sets the camera's initial values and computes its first + // view/projection here; editor_frame_callback only recomputes them when + // spin/velocity/zoom (or a resize) happen, so without this the initial + // view stays whatever ClientContext's zero-init left it as until the + // camera is first moved. + context->camera.rotation[0] = 3*M_PI/2; + context->camera.rotation[1] = M_PI/4; + context->camera.distance = 25; + camera_update_view(&context->camera); + camera_update_proj(&context->camera, (float)context->render.swapchain_extent.width/(float)context->render.swapchain_extent.height); + camera_sync_gpu(&context->camera, &context->render); + update_hex_picking_inverse(&context->camera, &context->hex); + // TODO: Remove when region mode is implemented add_hex_region(context); } @@ -392,32 +564,30 @@ EditorData* create_editor_data(void) { memset(data, 0, sizeof(EditorData)); data->selected_count = 0; resize_selected(data, 1); + data->spin_speed = 1.0; + data->zoom_speed = 0.5; + data->move_speed = 0.1; - data->mode_key_counts[MODE_NONE] = 15; + // Mode-switch keys (Escape/V/N/H/R) moved to script/editor_mode.lua, + // triggered by the engine.key event instead of this table + data->mode_key_counts[MODE_NONE] = 10; for(int i = 0; i < MODE_MAX_ENUM; i++) { data->mode_keys[i] = malloc(sizeof(ModeKey)*data->mode_key_counts[i]); } - // Mode Switches - data->mode_keys[MODE_NONE][0] = (ModeKey){GLFW_KEY_ESCAPE, clear_mode, 0, 0}; - data->mode_keys[MODE_NONE][1] = (ModeKey){GLFW_KEY_V, enter_vertex_mode, 0, 0}; - data->mode_keys[MODE_NONE][2] = (ModeKey){GLFW_KEY_N, enter_neighbor_mode, 0, 0}; - data->mode_keys[MODE_NONE][3] = (ModeKey){GLFW_KEY_H, enter_hex_mode, 0, 0}; - data->mode_keys[MODE_NONE][4] = (ModeKey){GLFW_KEY_R, enter_region_mode, 0, 0}; - // Camera Movement (axis: 0 = strafe x, 1 = up/down, 2 = forward/back) - data->mode_keys[MODE_NONE][5] = (ModeKey){GLFW_KEY_SPACE, move_cam_key, 1, 1}; - data->mode_keys[MODE_NONE][6] = (ModeKey){GLFW_KEY_LEFT_SHIFT, move_cam_key, 1, -1}; - data->mode_keys[MODE_NONE][7] = (ModeKey){GLFW_KEY_LEFT, move_cam_key, 0, -1}; - data->mode_keys[MODE_NONE][8] = (ModeKey){GLFW_KEY_RIGHT, move_cam_key, 0, 1}; - data->mode_keys[MODE_NONE][9] = (ModeKey){GLFW_KEY_UP, move_cam_key, 2, 1}; - data->mode_keys[MODE_NONE][10] = (ModeKey){GLFW_KEY_DOWN, move_cam_key, 2, -1}; + data->mode_keys[MODE_NONE][0] = (ModeKey){GLFW_KEY_SPACE, move_cam_key, 1, 1}; + data->mode_keys[MODE_NONE][1] = (ModeKey){GLFW_KEY_LEFT_SHIFT, move_cam_key, 1, -1}; + data->mode_keys[MODE_NONE][2] = (ModeKey){GLFW_KEY_LEFT, move_cam_key, 0, -1}; + data->mode_keys[MODE_NONE][3] = (ModeKey){GLFW_KEY_RIGHT, move_cam_key, 0, 1}; + data->mode_keys[MODE_NONE][4] = (ModeKey){GLFW_KEY_UP, move_cam_key, 2, 1}; + data->mode_keys[MODE_NONE][5] = (ModeKey){GLFW_KEY_DOWN, move_cam_key, 2, -1}; // Camera Spin (axis: 0 = yaw, 1 = pitch) - data->mode_keys[MODE_NONE][11] = (ModeKey){GLFW_KEY_A, spin_cam_key, 0, -1}; - data->mode_keys[MODE_NONE][12] = (ModeKey){GLFW_KEY_D, spin_cam_key, 0, 1}; - data->mode_keys[MODE_NONE][13] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1}; - data->mode_keys[MODE_NONE][14] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1}; + data->mode_keys[MODE_NONE][6] = (ModeKey){GLFW_KEY_A, spin_cam_key, 0, -1}; + data->mode_keys[MODE_NONE][7] = (ModeKey){GLFW_KEY_D, spin_cam_key, 0, 1}; + data->mode_keys[MODE_NONE][8] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1}; + data->mode_keys[MODE_NONE][9] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1}; return data; } diff --git a/client/src/editor_lua.c b/client/src/editor_lua.c new file mode 100644 index 0000000..9f79535 --- /dev/null +++ b/client/src/editor_lua.c @@ -0,0 +1,89 @@ +#include "editor_lua.h" + +#define EDITOR_LUA_CONTEXT "editor_context" + +// ClientContext lives once, heap-allocated for the process, never moves — +// unlike ui_lua.c's per-dispatch container/gpu pointers, one registry slot +// is enough here. +static ClientContext* current_context(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, EDITOR_LUA_CONTEXT); + ClientContext* context = lua_touserdata(L, -1); + lua_pop(L, 1); + return context; +} + +// Recomputes the camera's view and pushes it to the currently-rendered hex +// scene. Not gated behind any per-frame check - a script-driven camera move +// with no held input must be visible immediately. +static void refresh_camera(ClientContext* context) { + camera_update_view(&context->camera); + camera_sync_gpu(&context->camera, &context->render); + update_hex_picking_inverse(&context->camera, &context->hex); +} + +// camera.set_position(x, y, z) +static int lua_camera_set_position(lua_State* L) { + ClientContext* context = current_context(L); + context->camera.position[0] = luaL_checknumber(L, 1); + context->camera.position[1] = luaL_checknumber(L, 2); + context->camera.position[2] = luaL_checknumber(L, 3); + refresh_camera(context); + return 0; +} + +// camera.get_position() -> x, y, z +static int lua_camera_get_position(lua_State* L) { + Camera* camera = ¤t_context(L)->camera; + lua_pushnumber(L, camera->position[0]); + lua_pushnumber(L, camera->position[1]); + lua_pushnumber(L, camera->position[2]); + return 3; +} + +// camera.set_rotation(yaw, pitch) +static int lua_camera_set_rotation(lua_State* L) { + ClientContext* context = current_context(L); + context->camera.rotation[0] = luaL_checknumber(L, 1); + context->camera.rotation[1] = luaL_checknumber(L, 2); + refresh_camera(context); + return 0; +} + +// camera.get_rotation() -> yaw, pitch +static int lua_camera_get_rotation(lua_State* L) { + Camera* camera = ¤t_context(L)->camera; + lua_pushnumber(L, camera->rotation[0]); + lua_pushnumber(L, camera->rotation[1]); + return 2; +} + +// camera.set_distance(d) +static int lua_camera_set_distance(lua_State* L) { + ClientContext* context = current_context(L); + context->camera.distance = luaL_checknumber(L, 1); + refresh_camera(context); + return 0; +} + +// camera.get_distance() -> d +static int lua_camera_get_distance(lua_State* L) { + lua_pushnumber(L, current_context(L)->camera.distance); + return 1; +} + +void editor_lua_register(lua_State* L, ClientContext* context) { + lua_pushlightuserdata(L, context); + lua_setfield(L, LUA_REGISTRYINDEX, EDITOR_LUA_CONTEXT); + + static const luaL_Reg camera_funcs[] = { + {"set_position", lua_camera_set_position}, + {"get_position", lua_camera_get_position}, + {"set_rotation", lua_camera_set_rotation}, + {"get_rotation", lua_camera_get_rotation}, + {"set_distance", lua_camera_set_distance}, + {"get_distance", lua_camera_get_distance}, + {NULL, NULL}, + }; + luaL_newlib(L, camera_funcs); + lua_setglobal(L, "camera"); +} diff --git a/client/src/editor_main.c b/client/src/editor_main.c index e6eb26e..15d4e81 100644 --- a/client/src/editor_main.c +++ b/client/src/editor_main.c @@ -2,5 +2,5 @@ int main() { EditorData* data = create_editor_data(); - return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, NULL); + return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, editor_cursor_callback); } diff --git a/client/src/engine.c b/client/src/engine.c index f09000c..7dd927e 100644 --- a/client/src/engine.c +++ b/client/src/engine.c @@ -31,6 +31,13 @@ void key_callback(GLFWwindow* window, int key, int scancode, int action, int mod context->ui.active_element, key, action, mods)) return; + // Global visibility for scripts: fire-and-forget, doesn't consume or + // otherwise affect app_key's handling below + lua_pushinteger(context->ui.lua, key); + lua_pushinteger(context->ui.lua, action); + lua_pushinteger(context->ui.lua, mods); + event_emit(&context->events, "engine.key", EVENT_SOURCE_ENGINE, 3); + if(context->app_key != NULL) context->app_key(context, key, action, mods); } @@ -179,78 +186,28 @@ void cursor_callback(GLFWwindow* window, double xpos, double ypos) { int app_main(ClientContext* context) { VkResult result; - // double last_frame_time = 0; while(glfwWindowShouldClose(context->window) == 0) { double frame_time = glfwGetTime(); double delta_time = (frame_time - last_frame_time); - // Reset callback variables - context->zoom = 0; - glfwPollEvents(); - if(context->app_frame != NULL) context->app_frame(context); - - if((context->spin[0] != 0 || context->spin[1] != 0 || - context->velocity[0] != 0 || context->velocity[1] != 0 || context->velocity[2] != 0 || - context->zoom != 0 || - context->render.framebuffer_recreated == true)) { - - - if(context->render.framebuffer_recreated == true) { - context->render.framebuffer_recreated = false; - VK_RESULT(update_hex_proj(&context->render, &context->hex)); - VK_RESULT(update_ui_context_resolution(&context->ui, &context->render)); - } - - context->rotation[0] += (float)context->spin[0]*delta_time*context->spin_speed; - if(context->rotation[0] > 2*M_PI) { - context->rotation[0] -= 2*M_PI; - } else if(context->rotation[0] < 0) { - context->rotation[0] += 2*M_PI; - } - - context->rotation[1] += (float)context->spin[1]*delta_time*context->spin_speed; - if(context->rotation[1] > (M_PI/2 - 0.1)) { - context->rotation[1] = (M_PI/2 - 0.1); - } else if(context->rotation[1] < 0) { - context->rotation[1] = 0; - } - - float move_x = context->velocity[0]; - float move_z = context->velocity[2]; - float move_mag = sqrt(move_x*move_x + move_z*move_z); - if(move_mag > 1) { - move_x /= move_mag; - move_z /= move_mag; - } - - context->position[0] += - move_z*context->move_speed*cos(context->rotation[0]) - - move_x*context->move_speed*sin(context->rotation[0]); - - context->position[2] += move_x*context->move_speed*cos(context->rotation[0]) - - move_z*context->move_speed*sin(context->rotation[0]); - - context->position[1] += context->velocity[1]*context->move_speed; - - // context->zoom is a one-frame scroll impulse (reset to 0 each frame, - // above), not a held input like spin/velocity, so it isn't scaled by - // delta_time. - context->distance += context->zoom*context->zoom_speed; - if(context->distance < 1) { - context->distance = 1; - } - - VK_RESULT(update_hex_view( - context->position, - context->rotation, - context->distance, - &context->render, - &context->hex)); + if(context->app_frame != NULL) context->app_frame(context, delta_time); + + // Sole drain point: events queued by input callbacks, scripts, or the + // frame callback all dispatch here, before this frame is drawn + event_bus_drain(&context->events, &context->ui, &context->render, delta_time); + + // Resize handling here is limited to the UI system's own screen-space + // scale, which every container needs regardless of app. Which cameras/ + // containers track the window size (and how) is app policy - see + // editor_frame_callback's own resize check for the main view. + if(context->render.framebuffer_recreated == true) { + context->render.framebuffer_recreated = false; + VK_RESULT(update_ui_context_resolution(&context->ui, &context->render)); } - // - VkResult result = draw_frame(&context->render, &context->ui, &context->hex, frame_time); + VkResult result = draw_frame(&context->render, &context->ui, &context->hex, context->offscreen_camera, frame_time); if(result != VK_SUCCESS) { fprintf(stderr, "draw_frame error: %s\n", string_VkResult(result)); glfwDestroyWindow(context->window); @@ -281,13 +238,6 @@ int run_app( bool visible = getenv("ROLEPLAY_HEADLESS_TEST") == NULL; context->window = init_window(visible); - context->rotation[0] = 3*M_PI/2; - context->rotation[1] = M_PI/4; - context->distance = 25; - context->spin_speed = 1.0; - context->zoom_speed = 0.5; - context->move_speed = 0.1; - memset(&context->render, 0, sizeof(RenderContext)); memset(&context->ui, 0, sizeof(UIContext)); memset(&context->hex, 0, sizeof(HexContext)); @@ -312,6 +262,9 @@ int run_app( // TODO: make # of fonts/textures/containers scaling, recreate GPU buffers as necessary if(create_ui_context(10, 10, 10, &context->render, &context->ui) != VK_SUCCESS) return -3; if(create_hex_context(&context->render, &context->hex) != VK_SUCCESS) return -4; + if(create_camera(&context->render, &context->camera) != VK_SUCCESS) return -6; + if(event_bus_init(&context->events, context->ui.lua) != VK_SUCCESS) return -5; + context->ui.events = &context->events; if(app_startup != NULL) app_startup(context); diff --git a/client/src/events.c b/client/src/events.c new file mode 100644 index 0000000..1d5f98b --- /dev/null +++ b/client/src/events.c @@ -0,0 +1,424 @@ +#include "events.h" +#include "ui_lua.h" + +#include +#include +#include + +#define EVENT_LUA_BUS "event_bus" + +static EventBus* lua_bus(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, EVENT_LUA_BUS); + EventBus* bus = lua_touserdata(L, -1); + lua_pop(L, 1); + return bus; +} + +static VkResult grow(void** array, uint32_t count, uint32_t* cap, size_t stride) { + if(count < *cap) return VK_SUCCESS; + uint32_t new_cap = (*cap == 0) ? 8 : (*cap)*2; + void* grown = realloc(*array, new_cap*stride); + if(grown == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY; + *array = grown; + *cap = new_cap; + return VK_SUCCESS; +} + +// ---------- queue ---------- + +VkResult event_emit(EventBus* bus, const char* name, uint32_t source, int nargs) { + lua_State* L = bus->L; + + VkResult result = grow((void**)&bus->queue, bus->queue_count, &bus->queue_cap, sizeof(QueuedEvent)); + if(result != VK_SUCCESS) { + lua_pop(L, nargs); + return result; + } + + // Pack the nargs stack values into a table.pack-style {1..n, n=n} table + lua_createtable(L, nargs, 1); + lua_insert(L, -(nargs + 1)); + for(int i = nargs; i >= 1; i--) { + lua_rawseti(L, -(i + 1), i); + } + lua_pushinteger(L, nargs); + lua_setfield(L, -2, "n"); + + QueuedEvent* ev = &bus->queue[bus->queue_count]; + ev->args_ref = luaL_ref(L, LUA_REGISTRYINDEX); + ev->source = source; + ev->name = strdup(name); + if(ev->name == NULL) { + luaL_unref(L, LUA_REGISTRYINDEX, ev->args_ref); + return VK_ERROR_OUT_OF_HOST_MEMORY; + } + bus->queue_count += 1; + return VK_SUCCESS; +} + +// ---------- subscriptions ---------- + +static VkResult add_subscription( + EventBus* bus, + const char* name, + int script_env, + int lua_ref, + EventHandler handler, + void* userdata) { + VkResult result = grow((void**)&bus->subs, bus->sub_count, &bus->sub_cap, sizeof(EventSubscription)); + if(result != VK_SUCCESS) return result; + + EventSubscription* sub = &bus->subs[bus->sub_count]; + sub->event = strdup(name); + if(sub->event == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY; + sub->script_env = script_env; + sub->lua_ref = lua_ref; + sub->handler = handler; + sub->userdata = userdata; + sub->dead = 0; + bus->sub_count += 1; + return VK_SUCCESS; +} + +VkResult event_subscribe(EventBus* bus, const char* name, EventHandler handler, void* userdata) { + return add_subscription(bus, name, LUA_NOREF, LUA_NOREF, handler, userdata); +} + +static void compact_subscriptions(EventBus* bus) { + uint32_t out = 0; + for(uint32_t i = 0; i < bus->sub_count; i++) { + if(bus->subs[i].dead) continue; + bus->subs[out] = bus->subs[i]; + out += 1; + } + bus->sub_count = out; +} + +// ---------- drain ---------- + +static void dispatch_to_lua( + EventBus* bus, + QueuedEvent* ev, + EventSubscription* sub, + UIContext* ui, + RenderContext* gpu) { + lua_State* L = bus->L; + + lua_rawgeti(L, LUA_REGISTRYINDEX, ev->args_ref); + int args_index = lua_gettop(L); + lua_getfield(L, args_index, "n"); + int n = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + + if(!lua_checkstack(L, n + 4)) { + fprintf(stderr, "event %s: args overflow lua stack\n", ev->name); + lua_pop(L, 1); + return; + } + + // No specific overlay is "current" for a bus-fired handler; ui.rect/text + // require an explicit ui.create_overlay call first if one runs from here + ui_lua_set_current_script(L, sub->script_env); + ui_lua_set_current(L, NULL, ui, gpu); + + lua_rawgeti(L, LUA_REGISTRYINDEX, sub->lua_ref); + lua_pushinteger(L, ev->source); + for(int i = 1; i <= n; i++) { + lua_rawgeti(L, args_index, i); + } + lua_remove(L, args_index); + + if(lua_pcall(L, 1 + n, 0, 0) != LUA_OK) { + fprintf(stderr, "event %s: %s\n", ev->name, lua_tostring(L, -1)); + lua_pop(L, 1); + } +} + +// sub_count is re-read every iteration: handlers may subscribe during the +// dispatch, and late subscribers still receive the in-flight event +static void dispatch_event(EventBus* bus, QueuedEvent* ev, UIContext* ui, RenderContext* gpu) { + lua_State* L = bus->L; + for(uint32_t i = 0; i < bus->sub_count; i++) { + if(bus->subs[i].dead || strcmp(bus->subs[i].event, ev->name) != 0) continue; + + if(bus->subs[i].handler != NULL) { + lua_rawgeti(L, LUA_REGISTRYINDEX, ev->args_ref); + bus->subs[i].handler(bus->subs[i].userdata, ev->name, ev->source, L, lua_gettop(L)); + lua_pop(L, 1); + } else { + dispatch_to_lua(bus, ev, &bus->subs[i], ui, gpu); + } + } +} + +void event_bus_drain(EventBus* bus, UIContext* ui, RenderContext* gpu, double delta_time) { + lua_State* L = bus->L; + bus->draining = true; + + uint32_t head = 0; + uint32_t processed = 0; + while(head < bus->queue_count && processed < EVENT_DRAIN_MAX) { + // Copy: handlers can emit, which may realloc the queue + QueuedEvent ev = bus->queue[head]; + head += 1; + processed += 1; + + dispatch_event(bus, &ev, ui, gpu); + + luaL_unref(L, LUA_REGISTRYINDEX, ev.args_ref); + free(ev.name); + } + + if(head < bus->queue_count) { + fprintf(stderr, + "event_bus_drain: cap of %d hit, dropping %d events (handler cycle?)\n", + EVENT_DRAIN_MAX, + bus->queue_count - head); + for(uint32_t i = head; i < bus->queue_count; i++) { + fprintf(stderr, " dropped: %s (source %u)\n", bus->queue[i].name, bus->queue[i].source); + luaL_unref(L, LUA_REGISTRYINDEX, bus->queue[i].args_ref); + free(bus->queue[i].name); + } + } + bus->queue_count = 0; + + // engine.frame runs after the queue is empty so its handlers see the frame + // fully settled; anything they emit lands in next frame's drain (the queue + // was just reset). Skipped entirely when nothing subscribes, to avoid + // per-frame table garbage. + bool frame_wanted = false; + for(uint32_t i = 0; i < bus->sub_count; i++) { + if(!bus->subs[i].dead && strcmp(bus->subs[i].event, EVENT_FRAME) == 0) { + frame_wanted = true; + break; + } + } + if(frame_wanted) { + lua_createtable(L, 1, 1); + lua_pushnumber(L, delta_time); + lua_rawseti(L, -2, 1); + lua_pushinteger(L, 1); + lua_setfield(L, -2, "n"); + QueuedEvent frame_ev = { + .name = (char*)EVENT_FRAME, + .source = EVENT_SOURCE_ENGINE, + .args_ref = luaL_ref(L, LUA_REGISTRYINDEX), + }; + dispatch_event(bus, &frame_ev, ui, gpu); + luaL_unref(L, LUA_REGISTRYINDEX, frame_ev.args_ref); + } + + bus->draining = false; + compact_subscriptions(bus); +} + +// ---------- property registry ---------- + +Property* event_property(EventBus* bus, const char* name) { + for(uint32_t i = 0; i < bus->property_count; i++) { + if(strcmp(bus->properties[i].name, name) == 0) return &bus->properties[i]; + } + return NULL; +} + +VkResult event_property_register(EventBus* bus, const char* name, PropertyType type) { + if(event_property(bus, name) != NULL) return VK_ERROR_VALIDATION_FAILED_EXT; + + VkResult result = grow((void**)&bus->properties, bus->property_count, &bus->property_cap, sizeof(Property)); + if(result != VK_SUCCESS) return result; + + Property* p = &bus->properties[bus->property_count]; + memset(p, 0, sizeof(Property)); + p->name = strdup(name); + if(p->name == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY; + p->type = type; + bus->property_count += 1; + return VK_SUCCESS; +} + +// The changed value is already on top of the Lua stack +static VkResult emit_changed(EventBus* bus, Property* p, uint32_t source) { + char name[256]; + int written = snprintf(name, sizeof(name), EVENT_CHANGED_PREFIX "%s", p->name); + if(written < 0 || (size_t)written >= sizeof(name)) { + lua_pop(bus->L, 1); + return VK_ERROR_VALIDATION_FAILED_EXT; + } + return event_emit(bus, name, source, 1); +} + +VkResult event_property_set_number(EventBus* bus, const char* name, double value, uint32_t source) { + Property* p = event_property(bus, name); + if(p == NULL || p->type != PROPERTY_NUMBER) return VK_ERROR_VALIDATION_FAILED_EXT; + if(p->value.number == value) return VK_SUCCESS; + + p->value.number = value; + lua_pushnumber(bus->L, value); + return emit_changed(bus, p, source); +} + +VkResult event_property_set_string(EventBus* bus, const char* name, const char* value, uint32_t source) { + Property* p = event_property(bus, name); + if(p == NULL || p->type != PROPERTY_STRING || value == NULL) return VK_ERROR_VALIDATION_FAILED_EXT; + if(p->value.string != NULL && strcmp(p->value.string, value) == 0) return VK_SUCCESS; + + char* copy = strdup(value); + if(copy == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY; + free(p->value.string); + p->value.string = copy; + lua_pushstring(bus->L, value); + return emit_changed(bus, p, source); +} + +VkResult event_property_set_bool(EventBus* bus, const char* name, bool value, uint32_t source) { + Property* p = event_property(bus, name); + if(p == NULL || p->type != PROPERTY_BOOL) return VK_ERROR_VALIDATION_FAILED_EXT; + if(p->value.boolean == value) return VK_SUCCESS; + + p->value.boolean = value; + lua_pushboolean(bus->L, value); + return emit_changed(bus, p, source); +} + +// ---------- C handler arg accessors ---------- + +int event_arg_count(lua_State* L, int args_index) { + lua_getfield(L, args_index, "n"); + int n = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + return n; +} + +double event_arg_number(lua_State* L, int args_index, int i) { + lua_rawgeti(L, args_index, i); + double v = lua_tonumber(L, -1); + lua_pop(L, 1); + return v; +} + +bool event_arg_bool(lua_State* L, int args_index, int i) { + lua_rawgeti(L, args_index, i); + bool v = lua_toboolean(L, -1); + lua_pop(L, 1); + return v; +} + +const char* event_arg_string(lua_State* L, int args_index, int i) { + lua_rawgeti(L, args_index, i); + const char* v = lua_tostring(L, -1); + lua_pop(L, 1); + return v; +} + +// ---------- lua bindings ---------- + +// app.emit(name, ...) — queues a custom event from the calling container +static int lua_app_emit(lua_State* L) { + EventBus* bus = lua_bus(L); + const char* name = luaL_checkstring(L, 1); + if(strncmp(name, EVENT_ENGINE_PREFIX, strlen(EVENT_ENGINE_PREFIX)) == 0) { + return luaL_error(L, "emit: the %s* namespace is reserved for the engine", EVENT_ENGINE_PREFIX); + } + + Container* c = ui_lua_current_container(L); + uint32_t source = (c != NULL) ? c->id : EVENT_SOURCE_ENGINE; + + // Everything above the name is payload; emit consumes it off the stack + int nargs = lua_gettop(L) - 1; + if(event_emit(bus, name, source, nargs) != VK_SUCCESS) { + return luaL_error(L, "emit: failed to queue %s", name); + } + return 0; +} + +// app.subscribe(name, fn) — fn is called as fn(source, ...) during drains +static int lua_app_subscribe(lua_State* L) { + EventBus* bus = lua_bus(L); + const char* name = luaL_checkstring(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + + int script_env = ui_lua_current_script(L); + if(script_env == LUA_NOREF) { + return luaL_error(L, "subscribe: no script context"); + } + + lua_pushvalue(L, 2); + int ref = luaL_ref(L, LUA_REGISTRYINDEX); + if(add_subscription(bus, name, script_env, ref, NULL, NULL) != VK_SUCCESS) { + luaL_unref(L, LUA_REGISTRYINDEX, ref); + return luaL_error(L, "subscribe: failed to subscribe to %s", name); + } + return 0; +} + +// app.get(name) -> value or nil if the property is unregistered/unset +static int lua_app_get(lua_State* L) { + EventBus* bus = lua_bus(L); + Property* p = event_property(bus, luaL_checkstring(L, 1)); + if(p == NULL) { + lua_pushnil(L); + return 1; + } + + switch(p->type) { + case PROPERTY_NUMBER: lua_pushnumber(L, p->value.number); break; + case PROPERTY_BOOL: lua_pushboolean(L, p->value.boolean); break; + case PROPERTY_STRING: + if(p->value.string == NULL) lua_pushnil(L); + else lua_pushstring(L, p->value.string); + break; + } + return 1; +} + +// app.set(name, value) — writes a registered property; a real change emits +// engine.changed. with the calling container as the source +static int lua_app_set(lua_State* L) { + EventBus* bus = lua_bus(L); + const char* name = luaL_checkstring(L, 1); + Property* p = event_property(bus, name); + if(p == NULL) { + return luaL_error(L, "set: unknown property %s", name); + } + + Container* c = ui_lua_current_container(L); + uint32_t source = (c != NULL) ? c->id : EVENT_SOURCE_ENGINE; + + VkResult result = VK_SUCCESS; + switch(p->type) { + case PROPERTY_NUMBER: + result = event_property_set_number(bus, name, luaL_checknumber(L, 2), source); + break; + case PROPERTY_STRING: + result = event_property_set_string(bus, name, luaL_checkstring(L, 2), source); + break; + case PROPERTY_BOOL: + luaL_checktype(L, 2, LUA_TBOOLEAN); + result = event_property_set_bool(bus, name, lua_toboolean(L, 2), source); + break; + } + if(result != VK_SUCCESS) { + return luaL_error(L, "set: failed to set %s", name); + } + return 0; +} + +VkResult event_bus_init(EventBus* bus, lua_State* L) { + memset(bus, 0, sizeof(EventBus)); + bus->L = L; + + lua_pushlightuserdata(L, bus); + lua_setfield(L, LUA_REGISTRYINDEX, EVENT_LUA_BUS); + + static const luaL_Reg app_funcs[] = { + {"emit", lua_app_emit}, + {"subscribe", lua_app_subscribe}, + {"get", lua_app_get}, + {"set", lua_app_set}, + {NULL, NULL}, + }; + luaL_newlib(L, app_funcs); + lua_setglobal(L, "app"); + + return VK_SUCCESS; +} diff --git a/client/src/hex.c b/client/src/hex.c index 1f594cc..34fd52c 100644 --- a/client/src/hex.c +++ b/client/src/hex.c @@ -738,13 +738,6 @@ VkResult create_hex_context( VK_RESULT(create_point_pipeline(gpu, &context->point_pipeline)); memset(&context->data, 0, sizeof(GPUHexContext)); - glm_perspective( - PERSPECTIVE_FOVY, - (float)gpu->swapchain_extent.width/(float)gpu->swapchain_extent.height, - PERSPECTIVE_NEARZ, - PERSPECTIVE_FARZ, - context->data.proj); - glm_mat4_identity(context->data.view); for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { VK_RESULT(create_storage_buffer( gpu->allocator, @@ -1123,41 +1116,10 @@ bool ray_world_intersect( return intersect; } -VkResult update_hex_proj(RenderContext* gpu, HexContext* hex) { - glm_perspective( - PERSPECTIVE_FOVY, - (float)gpu->swapchain_extent.width/(float)gpu->swapchain_extent.height, - PERSPECTIVE_NEARZ, - PERSPECTIVE_FARZ, - hex->data.proj); - return add_transfers( - &hex->data.proj, - hex->context, - offsetof(GPUHexContext, proj), - sizeof(mat4), - gpu); -} - -vec3 up = {0, 1, 0}; - -VkResult update_hex_view( - vec3 position, - vec2 rotation, - double distance, - RenderContext* gpu, - HexContext* hex) { - vec3 camera = {}; - camera[0] = position[0] + distance*cos(rotation[1])*cos(rotation[0]); - camera[1] = position[1] + distance*sin(rotation[1]); - camera[2] = position[2] + distance*cos(rotation[1])*sin(rotation[0]); - - glm_lookat(camera, position, up, hex->data.view); - +void update_hex_picking_inverse(Camera* camera, HexContext* hex) { mat4 regular; - glm_mat4_mul(hex->data.proj, hex->data.view, regular); + glm_mat4_mul(camera->proj, camera->view, regular); glm_mat4_inv(regular, hex->inverse); - - return add_transfers(&hex->data, hex->context, 0, 2*sizeof(mat4), gpu); } void cursor_to_world_ray(RenderContext* gpu, mat4 inverse, double cursor[2], vec4 start, vec4 end) { diff --git a/client/src/ui.c b/client/src/ui.c index 0ec7108..2edc45c 100644 --- a/client/src/ui.c +++ b/client/src/ui.c @@ -15,6 +15,7 @@ VkResult create_ui_pipeline( VkDevice device, VkFormat color_format, + VkFormat depth_format, VkDescriptorSetLayout samplers_layout, VkDescriptorSetLayout textures_layout, GraphicsPipeline* pipeline, @@ -206,6 +207,18 @@ VkResult create_ui_pipeline( .sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO, .colorAttachmentCount = 1, .pColorAttachmentFormats = &color_format, + .depthAttachmentFormat = depth_format, + }; + + // UI draws inside the same rendering scope as screen-region camera + // passes (see draw_frame), which binds a depth attachment throughout - + // every pipeline used in that scope must declare a matching + // depthAttachmentFormat above. UI itself stays purely painter's-order via + // container_order: it never tests or writes depth. + VkPipelineDepthStencilStateCreateInfo depth_info = { + .sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO, + .depthTestEnable = VK_FALSE, + .depthWriteEnable = VK_FALSE, }; VkGraphicsPipelineCreateInfo draw_pipeline_info = { @@ -219,6 +232,7 @@ VkResult create_ui_pipeline( .pColorBlendState = &color_blend_info, .pDynamicState = &dynamic_info, .pMultisampleState = &multisample_info, + .pDepthStencilState = &depth_info, .pNext = &rendering_info, .layout = pipeline->layout, .renderPass = VK_NULL_HANDLE, @@ -672,17 +686,17 @@ VkResult unload_container( } } - // Drop focus without dispatching on_deselect: the script is being unloaded + // Drop focus without dispatching on_deselect: the container is being unloaded if(context->active_container == container) { context->active_container = NULL; context->active_element = 0; } - if(container->script_env != 0) { - luaL_unref(context->lua, LUA_REGISTRYINDEX, container->script_env); - } - if(container->script_path != NULL) { - free(container->script_path); + // script_env is not unref'd here: it belongs to the owning script, not + // this container, and other containers (or none) may still reference it. + // Bus subscriptions are keyed the same way and outlive any one container. + if(container->overlay_ref != LUA_NOREF) { + luaL_unref(context->lua, LUA_REGISTRYINDEX, container->overlay_ref); } memset(container, 0, sizeof(Container)); @@ -782,24 +796,21 @@ VkResult load_container( } c->id = input->id; + c->script_env = 0; + c->overlay_ref = LUA_NOREF; context->container_order[context->container_order_count] = index; context->container_order_count += 1; VK_RESULT(rebuild_indices(c, gpu)); - if(input->script_path != NULL) { - c->script_path = strdup(input->script_path); - VK_RESULT(ui_lua_load_container( - context->lua, - c, - context, - gpu, - input->script_path)); - } - return VK_SUCCESS; } +uint32_t ui_alloc_container_id(UIContext* context) { + context->next_container_id += 1; + return context->next_container_id; +} + VkResult load_texture( const char* png_path, RenderContext* gpu, @@ -1008,6 +1019,111 @@ VkResult load_texture( return VK_SUCCESS; } +VkResult create_render_target_texture( + RenderContext* gpu, + UIContext* context, + uint32_t width, + uint32_t height, + uint32_t* index) { + *index = 0xFFFFFFFF; + for(uint32_t i = 0; i < context->max_textures; i++) { + if(context->texture_slots[i].path == NULL) { + static const char placeholder[] = ""; + context->texture_slots[i].path = malloc(sizeof(placeholder)); + memcpy(context->texture_slots[i].path, placeholder, sizeof(placeholder)); + *index = i; + break; + } + } + if(*index == 0xFFFFFFFF) { + return VK_ERROR_OUT_OF_DEVICE_MEMORY; + } + + VkResult result; + + VkImageCreateInfo image_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + .extent = { + .width = width, + .height = height, + .depth = 1, + }, + .mipLevels = 1, + .arrayLayers = 1, + .format = gpu->swapchain_format.format, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .samples = VK_SAMPLE_COUNT_1_BIT, + .imageType = VK_IMAGE_TYPE_2D, + }; + + VmaAllocationCreateInfo memory_info = { + .usage = VMA_MEMORY_USAGE_GPU_ONLY, + }; + + VK_RESULT(vmaCreateImage(gpu->allocator, &image_info, &memory_info, &context->texture_slots[*index].image, &context->texture_slots[*index].image_memory, NULL)); + + VkImageViewCreateInfo view_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = context->texture_slots[*index].image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = gpu->swapchain_format.format, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .layerCount = 1, + .levelCount = 1, + }, + }; + + VK_RESULT(vkCreateImageView(gpu->device, &view_info, NULL, &context->texture_slots[*index].view)); + + VkSamplerCreateInfo sampler_info = { + .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, + .magFilter = VK_FILTER_LINEAR, + .minFilter = VK_FILTER_LINEAR, + .addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + }; + + VK_RESULT(vkCreateSampler(gpu->device, &sampler_info, NULL, &context->texture_slots[*index].sampler)); + + VkDescriptorImageInfo desc_sampler_info = { + .sampler = context->texture_slots[*index].sampler, + }; + + VkDescriptorImageInfo desc_image_info = { + .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + .imageView = context->texture_slots[*index].view, + }; + + VkWriteDescriptorSet desc_writes[] = { + { + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = context->textures, + .dstBinding = 0, + .dstArrayElement = *index, + .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, + .descriptorCount = 1, + .pImageInfo = &desc_image_info, + }, + { + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = context->samplers, + .dstBinding = 0, + .dstArrayElement = *index, + .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER, + .descriptorCount = 1, + .pImageInfo = &desc_sampler_info, + }, + }; + + vkUpdateDescriptorSets(gpu->device, sizeof(desc_writes)/sizeof(VkWriteDescriptorSet), desc_writes, 0, NULL); + + return VK_SUCCESS; +} + VkResult load_font( uint32_t index, const char* ttf_file, @@ -1528,6 +1644,7 @@ VkResult create_ui_context( VK_RESULT(create_ui_pipeline( gpu->device, gpu->swapchain_format.format, + gpu->depth_format, context->samplers_layout, context->textures_layout, &context->pipeline, @@ -1545,6 +1662,7 @@ VkResult create_ui_context( memset(context->containers, 0, max_containers*sizeof(Container)); context->container_order = malloc(max_containers*sizeof(uint32_t)); context->container_order_count = 0; + context->next_container_id = 0; context->lua = luaL_newstate(); if(context->lua == NULL) { @@ -1759,3 +1877,17 @@ void anchor_offset(RenderContext* gpu, Container* container, vec2 offset) { break; } } + +void container_set_camera(Container* container, Camera* camera) { + container->camera = camera; +} + +void container_screen_rect(RenderContext* gpu, Container* container, VkRect2D* out) { + vec2 offset = {container->data.offset[0], container->data.offset[1]}; + anchor_offset(gpu, container, offset); + + out->offset.x = (int32_t)(offset[0]*gpu->window_scale[0]); + out->offset.y = (int32_t)(offset[1]*gpu->window_scale[1]); + out->extent.width = (uint32_t)(container->data.size[0]*gpu->window_scale[0]); + out->extent.height = (uint32_t)(container->data.size[1]*gpu->window_scale[1]); +} diff --git a/client/src/ui_lua.c b/client/src/ui_lua.c index 37b2b59..241f12c 100644 --- a/client/src/ui_lua.c +++ b/client/src/ui_lua.c @@ -10,8 +10,17 @@ #define UI_LUA_CONTAINER "ui_container" #define UI_LUA_CONTEXT "ui_context" #define UI_LUA_GPU "ui_gpu" +#define UI_LUA_SCRIPT "ui_script" #define UI_LUA_ELEMENT_META "ui_element" +#define UI_LUA_OVERLAY_META "ui_overlay" + +// Opaque overlay handle passed to scripts. Containers are never recycled +// under a reused id (UIContext.next_container_id only counts up), so unlike +// element handles, a plain id lookup is enough to detect a destroyed overlay. +typedef struct UIOverlayHandleStruct { + uint32_t container_id; +} UIOverlayHandle; // Opaque element handle passed to scripts. container_id + generation let the // binding detect stale handles (container unloaded / slot recycled) and raise @@ -39,6 +48,18 @@ static void set_registry_pointers(lua_State* L, Container* c, UIContext* ui, Ren lua_setfield(L, LUA_REGISTRYINDEX, UI_LUA_GPU); } +int ui_lua_current_script(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, UI_LUA_SCRIPT); + int ref = lua_isnil(L, -1) ? LUA_NOREF : (int)lua_tointeger(L, -1); + lua_pop(L, 1); + return ref; +} + +void ui_lua_set_current_script(lua_State* L, int script_env) { + lua_pushinteger(L, script_env); + lua_setfield(L, LUA_REGISTRYINDEX, UI_LUA_SCRIPT); +} + // ---------- handle plumbing ---------- static UIElementHandle* check_handle(lua_State* L, int idx) { @@ -95,6 +116,42 @@ static void push_element_handle(lua_State* L, Container* c, uint32_t element) { } } +// ---------- overlay handle plumbing ---------- + +static UIOverlayHandle* check_overlay(lua_State* L, int idx) { + return luaL_checkudata(L, idx, UI_LUA_OVERLAY_META); +} + +static Container* resolve_overlay(lua_State* L, UIOverlayHandle* h) { + UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); + Container* c = context_container(h->container_id, ui); + if(c == NULL) { + luaL_error(L, "overlay %d is destroyed", h->container_id); + } + return c; +} + +// Creates the container's one overlay handle and caches its ref so every +// later dispatch/lookup hands back the same object (scripts compare +// overlays by identity, e.g. `overlay == picker_overlay`) +static void push_new_overlay_handle(lua_State* L, Container* c) { + UIOverlayHandle* h = lua_newuserdata(L, sizeof(UIOverlayHandle)); + h->container_id = c->id; + luaL_setmetatable(L, UI_LUA_OVERLAY_META); + + lua_pushvalue(L, -1); + c->overlay_ref = luaL_ref(L, LUA_REGISTRYINDEX); +} + +// Pushes the container's cached overlay handle (created by ui.create_overlay) +static void push_overlay_handle(lua_State* L, Container* c) { + if(c->overlay_ref == LUA_NOREF) { + lua_pushnil(L); + } else { + lua_rawgeti(L, LUA_REGISTRYINDEX, c->overlay_ref); + } +} + // ---------- declaration table parsing ---------- static float opt_number_field(lua_State* L, int table, const char* key, float def) { @@ -171,6 +228,9 @@ static void opt_corner_colors(lua_State* L, int table, vec4 corners[4]) { static int lua_ui_rect(lua_State* L) { Container* container = registry_pointer(L, UI_LUA_CONTAINER); RenderContext* gpu = registry_pointer(L, UI_LUA_GPU); + if(container == NULL) { + return luaL_error(L, "ui.rect: no active overlay (call ui.create_overlay first)"); + } luaL_checktype(L, 1, LUA_TTABLE); GPUDrawable drawable; @@ -197,6 +257,9 @@ static int lua_ui_text(lua_State* L) { Container* container = registry_pointer(L, UI_LUA_CONTAINER); UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); RenderContext* gpu = registry_pointer(L, UI_LUA_GPU); + if(container == NULL) { + return luaL_error(L, "ui.text: no active overlay (call ui.create_overlay first)"); + } luaL_checktype(L, 1, LUA_TTABLE); GPUString string; @@ -385,6 +448,28 @@ static int lua_element_remove(lua_State* L) { return 0; } +// ---------- overlay handle methods ---------- + +// overlay:to_front() +static int lua_overlay_to_front(lua_State* L) { + UIOverlayHandle* h = check_overlay(L, 1); + UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); + resolve_overlay(L, h); + ui_container_to_front(h->container_id, ui); + return 0; +} + +// overlay:destroy() +static int lua_overlay_destroy(lua_State* L) { + UIOverlayHandle* h = check_overlay(L, 1); + resolve_overlay(L, h); + UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); + RenderContext* gpu = registry_pointer(L, UI_LUA_GPU); + + unload_container(h->container_id, gpu, ui); + return 0; +} + // ---------- ui table functions ---------- // ui.blur() @@ -396,13 +481,33 @@ static int lua_ui_blur(lua_State* L) { return 0; } -// ui.raise() — bring the calling script's container to the front -static int lua_ui_raise(lua_State* L) { - Container* container = registry_pointer(L, UI_LUA_CONTAINER); +// ui.create_overlay{anchor=, offset=, size=} -> overlay handle +// +// Creates a container and attributes it to whichever script is currently +// running (see ui_lua_current_script). Becomes the active overlay for any +// ui.rect/ui.text calls that follow, in this call or later ones from the +// same script, until another ui.create_overlay changes it. +static int lua_ui_create_overlay(lua_State* L) { UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); + RenderContext* gpu = registry_pointer(L, UI_LUA_GPU); + luaL_checktype(L, 1, LUA_TTABLE); - ui_container_to_front(container->id, ui); - return 0; + ContainerInput input = {0}; + input.id = ui_alloc_container_id(ui); + input.anchor = opt_integer_field(L, 1, "anchor", ANCHOR_TOP_LEFT); + opt_vec2_field(L, 1, "offset", input.offset); + opt_vec2_field(L, 1, "size", input.size); + + if(load_container(&input, gpu, ui) != VK_SUCCESS) { + return luaL_error(L, "ui.create_overlay: failed to create overlay"); + } + + Container* c = context_container(input.id, ui); + c->script_env = ui_lua_current_script(L); + + push_new_overlay_handle(L, c); + set_registry_pointers(L, c, ui, gpu); + return 1; } // ui.hsv_to_rgb(h, s, v) -> r, g, b @@ -437,12 +542,12 @@ static int lua_ui_rgb_to_hsv(lua_State* L) { void ui_lua_register(lua_State* L) { static const luaL_Reg ui_funcs[] = { - {"rect", lua_ui_rect}, - {"text", lua_ui_text}, - {"blur", lua_ui_blur}, - {"raise", lua_ui_raise}, - {"hsv_to_rgb", lua_ui_hsv_to_rgb}, - {"rgb_to_hsv", lua_ui_rgb_to_hsv}, + {"rect", lua_ui_rect}, + {"text", lua_ui_text}, + {"blur", lua_ui_blur}, + {"create_overlay", lua_ui_create_overlay}, + {"hsv_to_rgb", lua_ui_hsv_to_rgb}, + {"rgb_to_hsv", lua_ui_rgb_to_hsv}, {NULL, NULL}, }; luaL_newlib(L, ui_funcs); @@ -464,6 +569,22 @@ void ui_lua_register(lua_State* L) { lua_setfield(L, -2, "__index"); lua_pop(L, 1); + static const luaL_Reg overlay_methods[] = { + {"to_front", lua_overlay_to_front}, + {"destroy", lua_overlay_destroy}, + {NULL, NULL}, + }; + luaL_newmetatable(L, UI_LUA_OVERLAY_META); + luaL_newlib(L, overlay_methods); + lua_setfield(L, -2, "__index"); + lua_pop(L, 1); + + lua_pushinteger(L, ANCHOR_TOP_LEFT); lua_setglobal(L, "ANCHOR_TOP_LEFT"); + lua_pushinteger(L, ANCHOR_TOP_RIGHT); lua_setglobal(L, "ANCHOR_TOP_RIGHT"); + lua_pushinteger(L, ANCHOR_BOTTOM_LEFT); lua_setglobal(L, "ANCHOR_BOTTOM_LEFT"); + lua_pushinteger(L, ANCHOR_BOTTOM_RIGHT); lua_setglobal(L, "ANCHOR_BOTTOM_RIGHT"); + lua_pushinteger(L, ANCHOR_CENTER); lua_setglobal(L, "ANCHOR_CENTER"); + lua_pushinteger(L, GLFW_PRESS); lua_setglobal(L, "PRESS"); lua_pushinteger(L, GLFW_RELEASE); lua_setglobal(L, "RELEASE"); lua_pushinteger(L, GLFW_MOUSE_BUTTON_LEFT); lua_setglobal(L, "MOUSE_LEFT"); @@ -472,6 +593,10 @@ void ui_lua_register(lua_State* L) { lua_pushinteger(L, GLFW_KEY_ESCAPE); lua_setglobal(L, "KEY_ESCAPE"); lua_pushinteger(L, GLFW_KEY_ENTER); lua_setglobal(L, "KEY_ENTER"); lua_pushinteger(L, GLFW_KEY_BACKSPACE); lua_setglobal(L, "KEY_BACKSPACE"); + lua_pushinteger(L, GLFW_KEY_V); lua_setglobal(L, "KEY_V"); + lua_pushinteger(L, GLFW_KEY_N); lua_setglobal(L, "KEY_N"); + lua_pushinteger(L, GLFW_KEY_H); lua_setglobal(L, "KEY_H"); + lua_pushinteger(L, GLFW_KEY_R); lua_setglobal(L, "KEY_R"); lua_pushinteger(L, DRAWABLE_TYPE_RECT); lua_setglobal(L, "RECT"); lua_pushinteger(L, DRAWABLE_TYPE_RECT_HSV); lua_setglobal(L, "RECT_HSV"); @@ -481,15 +606,14 @@ void ui_lua_register(lua_State* L) { lua_pushinteger(L, UI_EVENT_CURSOR); lua_setglobal(L, "EVENT_CURSOR"); } -VkResult ui_lua_load_container( +VkResult ui_lua_run_script( lua_State* L, - Container* c, UIContext* ui, RenderContext* gpu, const char* path) { if(luaL_loadfile(L, path) != LUA_OK) { - fprintf(stderr, "ui_lua_load_container: %s\n", lua_tostring(L, -1)); + fprintf(stderr, "ui_lua_run_script: %s\n", lua_tostring(L, -1)); lua_pop(L, 1); return VK_ERROR_UNKNOWN; } @@ -506,16 +630,17 @@ VkResult ui_lua_load_container( lua_setupvalue(L, -3, 1); // Ref the env first so the chunk (now at the top) can be called - c->script_env = luaL_ref(L, LUA_REGISTRYINDEX); + int script_env = luaL_ref(L, LUA_REGISTRYINDEX); - // Top-level script code declares elements via ui.* during execution - set_registry_pointers(L, c, ui, gpu); + // No container exists yet — the script creates its own via + // ui.create_overlay, whenever it wants one + ui_lua_set_current_script(L, script_env); + set_registry_pointers(L, NULL, ui, gpu); if(lua_pcall(L, 0, 0, 0) != LUA_OK) { - fprintf(stderr, "ui_lua_load_container: %s\n", lua_tostring(L, -1)); + fprintf(stderr, "ui_lua_run_script: %s\n", lua_tostring(L, -1)); lua_pop(L, 1); - luaL_unref(L, LUA_REGISTRYINDEX, c->script_env); - c->script_env = 0; + luaL_unref(L, LUA_REGISTRYINDEX, script_env); return VK_ERROR_UNKNOWN; } @@ -533,6 +658,7 @@ static bool ui_lua_begin_dispatch( if(c == NULL || c->script_env == 0) return false; set_registry_pointers(L, c, ui, gpu); + ui_lua_set_current_script(L, c->script_env); lua_rawgeti(L, LUA_REGISTRYINDEX, c->script_env); lua_getfield(L, -1, handler); @@ -556,23 +682,16 @@ static bool ui_lua_finish_dispatch(lua_State* L, const char* handler, int nargs) return consumed; } -VkResult ui_lua_call( - UIContext* ui, - RenderContext* gpu, +Container* ui_lua_current_container(lua_State* L) { + return registry_pointer(L, UI_LUA_CONTAINER); +} + +void ui_lua_set_current( + lua_State* L, Container* c, - const char* function, - const char* argument) { - lua_State* L = ui->lua; - if(!ui_lua_begin_dispatch(L, ui, gpu, c, function)) { - return VK_ERROR_UNKNOWN; - } - lua_pushstring(L, argument); - if(lua_pcall(L, 1, 0, 0) != LUA_OK) { - fprintf(stderr, "%s: %s\n", function, lua_tostring(L, -1)); - lua_pop(L, 1); - return VK_ERROR_UNKNOWN; - } - return VK_SUCCESS; + UIContext* ui, + RenderContext* gpu) { + set_registry_pointers(L, c, ui, gpu); } bool ui_lua_dispatch_button( @@ -587,13 +706,14 @@ bool ui_lua_dispatch_button( int mods) { lua_State* L = ui->lua; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_button")) return false; + push_overlay_handle(L, c); push_element_handle(L, c, element); lua_pushnumber(L, x); lua_pushnumber(L, y); lua_pushinteger(L, button); lua_pushinteger(L, action); lua_pushinteger(L, mods); - return ui_lua_finish_dispatch(L, "on_button", 6); + return ui_lua_finish_dispatch(L, "on_button", 7); } bool ui_lua_dispatch_cursor( @@ -605,10 +725,11 @@ bool ui_lua_dispatch_cursor( float y) { lua_State* L = ui->lua; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_cursor")) return false; + push_overlay_handle(L, c); push_element_handle(L, c, element); lua_pushnumber(L, x); lua_pushnumber(L, y); - return ui_lua_finish_dispatch(L, "on_cursor", 3); + return ui_lua_finish_dispatch(L, "on_cursor", 4); } bool ui_lua_dispatch_scroll( @@ -620,10 +741,11 @@ bool ui_lua_dispatch_scroll( double y) { lua_State* L = ui->lua; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_scroll")) return false; + push_overlay_handle(L, c); push_element_handle(L, c, element); lua_pushnumber(L, x); lua_pushnumber(L, y); - return ui_lua_finish_dispatch(L, "on_scroll", 3); + return ui_lua_finish_dispatch(L, "on_scroll", 4); } bool ui_lua_dispatch_key( @@ -636,11 +758,12 @@ bool ui_lua_dispatch_key( int mods) { lua_State* L = ui->lua; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_key")) return false; + push_overlay_handle(L, c); push_element_handle(L, c, element); lua_pushinteger(L, key); lua_pushinteger(L, action); lua_pushinteger(L, mods); - return ui_lua_finish_dispatch(L, "on_key", 4); + return ui_lua_finish_dispatch(L, "on_key", 5); } bool ui_lua_dispatch_text( @@ -651,9 +774,10 @@ bool ui_lua_dispatch_text( unsigned int codepoint) { lua_State* L = ui->lua; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_text")) return false; + push_overlay_handle(L, c); push_element_handle(L, c, element); lua_pushinteger(L, codepoint); - return ui_lua_finish_dispatch(L, "on_text", 2); + return ui_lua_finish_dispatch(L, "on_text", 3); } bool ui_lua_dispatch_deselect( @@ -663,6 +787,7 @@ bool ui_lua_dispatch_deselect( uint32_t element) { lua_State* L = ui->lua; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_deselect")) return false; + push_overlay_handle(L, c); push_element_handle(L, c, element); - return ui_lua_finish_dispatch(L, "on_deselect", 1); + return ui_lua_finish_dispatch(L, "on_deselect", 2); } diff --git a/client/test/editor.c b/client/test/editor.c index 5edd834..0815f73 100644 --- a/client/test/editor.c +++ b/client/test/editor.c @@ -17,7 +17,7 @@ static int failures = 0; // land a synthetic click on a given hex vertex/center. void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2]) { mat4 view_proj; - glm_mat4_mul(context->hex.data.proj, context->hex.data.view, view_proj); + glm_mat4_mul(context->camera.proj, context->camera.view, view_proj); vec4 point = {world_point[0], world_point[1], world_point[2], 1.0f}; vec4 clip; @@ -34,17 +34,20 @@ void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2]) // matrices the real per-frame update would - required up front since a // one-shot app_frame callback runs before app_main's own camera-update step. void setup_camera(ClientContext* context) { - context->position[0] = 0; - context->position[1] = 0; - context->position[2] = 0; + Camera* camera = &context->camera; + camera->position[0] = 0; + camera->position[1] = 0; + camera->position[2] = 0; // rotation[1] intentionally avoids M_PI/2 (straight down), which is // parallel to the {0,1,0} up vector and singular for glm_lookat. - context->rotation[0] = 0; - context->rotation[1] = 1.3f; - context->distance = 10; - - update_hex_proj(&context->render, &context->hex); - update_hex_view(context->position, context->rotation, context->distance, &context->render, &context->hex); + camera->rotation[0] = 0; + camera->rotation[1] = 1.3f; + camera->distance = 10; + + camera_update_proj(camera, (float)context->render.swapchain_extent.width/(float)context->render.swapchain_extent.height); + camera_update_view(camera); + camera_sync_gpu(camera, &context->render); + update_hex_picking_inverse(camera, &context->hex); } void click(ClientContext* context, vec3 world_point, int mods) { @@ -53,10 +56,18 @@ void click(ClientContext* context, vec3 world_point, int mods) { editor_button_callback(context, (float)cursor[0], (float)cursor[1], GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, mods); } +// editor_set_mode only writes the editor.mode property; the reset-selection +// reaction lives in a bus subscriber, so a real mode change requires a +// drain to take effect - same as it would via a key press in the real app. +void set_mode(ClientContext* context, EditorMode mode) { + editor_set_mode(context, mode); + event_bus_drain(&context->events, &context->ui, &context->render, 0); +} + void test_hex_mode_click_adds(ClientContext* context) { EditorData* data = context->app_data; - clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); - enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_NONE); + set_mode(context, MODE_HEX); vec3 hex0_center = {0, 0, 0}; click(context, hex0_center, 0); @@ -73,8 +84,8 @@ void test_hex_mode_click_adds(ClientContext* context) { void test_hex_mode_ctrl_click_removes(ClientContext* context) { EditorData* data = context->app_data; - clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); - enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_NONE); + set_mode(context, MODE_HEX); vec3 hex0_center = {0, 0, 0}; vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]}; @@ -90,8 +101,8 @@ void test_hex_mode_ctrl_click_removes(ClientContext* context) { void test_vertex_mode_edge_only_picking(ClientContext* context) { EditorData* data = context->app_data; - clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); - enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_NONE); + set_mode(context, MODE_VERTEX); // Clicking dead center of the hex would, without edge-only picking, hit // vertex 0 (the center) and select nothing. @@ -104,8 +115,8 @@ void test_vertex_mode_edge_only_picking(ClientContext* context) { void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) { EditorData* data = context->app_data; - clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); - enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_NONE); + set_mode(context, MODE_VERTEX); vec3 v1 = {hex_vertices[0][0], hex_vertices[0][1], hex_vertices[0][2]}; vec3 v4 = {hex_vertices[3][0], hex_vertices[3][1], hex_vertices[3][2]}; @@ -120,18 +131,18 @@ void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) { void test_mode_switch_resets_selection(ClientContext* context) { EditorData* data = context->app_data; - enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_HEX); click(context, (vec3){0, 0, 0}, 0); CHECK(data->selected_count == 1, "setup: a hex should be selected before the mode switch"); - enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_VERTEX); CHECK(data->selected_count == 0, "switching modes should reset the selection"); } void test_empty_space_click_is_noop(ClientContext* context) { EditorData* data = context->app_data; - clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); - enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); + set_mode(context, MODE_NONE); + set_mode(context, MODE_HEX); // A cursor coordinate this far outside the window unprojects to a ray // angled far off the view axis - guaranteed to miss the small hex region @@ -140,11 +151,39 @@ void test_empty_space_click_is_noop(ClientContext* context) { CHECK(data->selected_count == 0, "clicking empty space should not select anything"); } -void test_frame_callback(ClientContext* context) { +// Regression test: editor_startup must compute the initial view matrix +// itself. It used to rely on init_vulkan's first-frame framebuffer_recreated +// flag incidentally running the (then-combined) resize+camera update block; +// once camera integration moved into editor_frame_callback and became +// gated purely on spin/velocity/zoom, that incidental trigger disappeared, +// and the hex region was invisible until the camera was first moved. Must +// run before setup_camera, which would otherwise mask the bug by computing +// its own view matrix first. +// +// ClientContext is zero-initialized (run_app's memset), so all-zero - not +// identity - is the untouched/broken sentinel to check against; a real +// glm_lookat result for any non-degenerate camera can't be all-zero. +void test_initial_camera_view_is_computed(ClientContext* context) { + bool is_zero = true; + for(int col = 0; col < 4 && is_zero; col++) { + for(int row = 0; row < 4; row++) { + if(context->camera.view[col][row] != 0.0f) { + is_zero = false; + break; + } + } + } + CHECK(!is_zero, "editor_startup computes the initial view matrix without requiring camera movement"); +} + +void test_frame_callback(ClientContext* context, double delta_time) { + (void)delta_time; static bool ran = false; if(ran) return; ran = true; + test_initial_camera_view_is_computed(context); + setup_camera(context); test_hex_mode_click_adds(context);