Added EventBus logic and moved camera to be drawn on Containers

main
noah metz 2026-07-28 16:11:33 -06:00
parent 8252418db9
commit 1f2118f210
32 changed files with 2195 additions and 730 deletions

@ -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)))

@ -0,0 +1,78 @@
#ifndef CAMERA_H
#define CAMERA_H
#include <cglm/types.h>
#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

@ -9,6 +9,7 @@ VkResult draw_frame(
RenderContext* context,
UIContext* ui,
HexContext* hex,
Camera* offscreen_camera,
double time);
#endif

@ -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);

@ -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

@ -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;

@ -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.<property>".
#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.<name> 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

@ -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

@ -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);

@ -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(

@ -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,

@ -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)

@ -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)

@ -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)

@ -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")

@ -0,0 +1,4 @@
layout(std430, buffer_reference) readonly buffer Camera {
mat4 proj;
mat4 view;
};

@ -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;
}

@ -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;

@ -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);

@ -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);

@ -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;
}

@ -0,0 +1,85 @@
#include "camera.h"
#include "ui.h"
#include <cglm/cam.h>
#include <math.h>
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;
}

@ -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 = {

@ -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 <math.h>
#include <stdlib.h>
#define COLOR_PICK_CONTAINER_ID 0x02
#define MODE_STRING_CONTAINER_ID 0x01
#include <string.h>
#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;
}

@ -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 = &current_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 = &current_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");
}

@ -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);
}

@ -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);

@ -0,0 +1,424 @@
#include "events.h"
#include "ui_lua.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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.<name> 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;
}

@ -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) {

@ -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[] = "<render-target>";
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]);
}

@ -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);
}

@ -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);