diff --git a/druid/druid.script b/druid/druid.script new file mode 100644 index 0000000..209ba74 --- /dev/null +++ b/druid/druid.script @@ -0,0 +1,37 @@ +-- Place this script nearby with the gui component to able make requests +-- To the go namespace from GUI with events systems (cross context) + +local event_queue = require("druid.event_queue") + +---Usage: event_queue.request("druid.get_atlas_path", callback, gui.get_texture(self.node), msg.url()) +---Pass texture name to get atlas info and sender url to check if the request is valid +local MESSAGE_GET_ATLAS_PATH = "druid.get_atlas_path" + + +---@param texture_name hash The name from gui.get_texture(node) +---@param sender hash Just msg.url from the caller +local function get_atlas_path(texture_name, sender) + local my_url = msg.url() + my_url.fragment = nil + + local copy_url = msg.url(sender) + copy_url.fragment = nil + + -- This check should works well + local is_my_url = my_url == copy_url + if not is_my_url then + return nil + end + + return go.get(sender, "textures", { key = texture_name }) +end + + +function init(self) + event_queue.subscribe(MESSAGE_GET_ATLAS_PATH, get_atlas_path) +end + + +function final(self) + event_queue.unsubscribe(MESSAGE_GET_ATLAS_PATH, get_atlas_path) +end \ No newline at end of file diff --git a/druid/event_queue.lua b/druid/event_queue.lua new file mode 100644 index 0000000..cdeb7c2 --- /dev/null +++ b/druid/event_queue.lua @@ -0,0 +1,84 @@ +local event = require("event.event") + +---@class event.queue +local M = {} + +local event_handlers = {} +local pending_callbacks = {} + + +---Request to handle a specified event and processes the queue of callbacks associated with it. +---If event has already been triggered, the callback will be executed immediately. +---If event not triggered yet, callback will be executed when event will be triggered. +---It triggered only once and then removed from the queue. +---@param event_name string The name of the event to trigger. +---@param callback fun() The callback function to execute upon triggering. +---@param ... any Additional arguments for the callback. +function M.request(event_name, callback, ...) + pending_callbacks[event_name] = pending_callbacks[event_name] or {} + table.insert(pending_callbacks[event_name], { event.create(callback), ... }) + + M.process_pending_callbacks(event_name) +end + + +---Subscribes to a specified event and executes a callback when the event is triggered. +-- If the event has already been triggered, the callback will be executed immediately. +---@param event_name string The name of the event to subscribe to. +---@param callback fun() The function to call when the event is triggered. +function M.subscribe(event_name, callback) + event_handlers[event_name] = event_handlers[event_name] or event.create() + + if event_handlers[event_name] then + event_handlers[event_name]:subscribe(callback) + end + + M.process_pending_callbacks(event_name) +end + + +---Unsubscribes a callback function from a specified event. +---@param event_name string The name of the event to unsubscribe from. +---@param callback fun() The function to remove from the event's subscription list. +function M.unsubscribe(event_name, callback) + if event_handlers[event_name] then + event_handlers[event_name]:unsubscribe(callback) + end +end + + +---Processes the queue for a given event name, executing callbacks and handling results. +---Processed callbacks are removed from the queue. +---@param event_name string The name of the event for which to process the queue. +function M.process_pending_callbacks(event_name) + local callbacks_to_process = pending_callbacks[event_name] + local event_handler = event_handlers[event_name] + + if not callbacks_to_process or not event_handler then + return + end + + -- Loop through the queue in reverse to prevent index errors during removal + for i = #callbacks_to_process, 1, -1 do + local callback_entry = callbacks_to_process[i] + -- Better to figure out how to make it without 2 unpacks, but ok for all our cases now + local args = { unpack(callback_entry, 2) } + + -- Safely call the event handler and handle errors + local success, result = pcall(event_handler.trigger, event_handler, unpack(args)) + + if success and result then + local callback_function = callback_entry[1] + pcall(callback_function, result) -- Safely invoke the callback, catching any errors + table.remove(callbacks_to_process, i) -- Remove the processed callback from the queue + end + end + + -- Clean up if the callback queue is empty + if #callbacks_to_process == 0 then + pending_callbacks[event_name] = nil + end +end + + +return M diff --git a/druid/helper.lua b/druid/helper.lua index 163a75c..214d14b 100644 --- a/druid/helper.lua +++ b/druid/helper.lua @@ -12,6 +12,11 @@ local POSITION_X = hash("position.x") local SCALE_X = hash("scale.x") local SIZE_X = hash("size.x") +M.PROP_SIZE_X = hash("size.x") +M.PROP_SIZE_Y = hash("size.y") +M.PROP_SCALE_X = hash("scale.x") +M.PROP_SCALE_Y = hash("scale.y") + local function get_text_width(text_node) if text_node then local text_metrics = M.get_text_metrics_from_node(text_node) @@ -540,4 +545,121 @@ function M.get_full_position(node, root) end +---@class druid.animation_data +---@field frames table> @List of frames with uv coordinates and size +---@field width number @Width of the animation +---@field height number @Height of the animation +---@field fps number @Frames per second +---@field current_frame number @Current frame +---@field node node @Node with flipbook animation +---@field v vector4 @Vector with UV coordinates and size + +---@param node node +---@param atlas_path string @Path to the atlas +---@return druid.animation_data +function M.get_animation_data_from_node(node, atlas_path) + local atlas_data = resource.get_atlas(atlas_path) + local tex_info = resource.get_texture_info(atlas_data.texture) + local tex_w = tex_info.width + local tex_h = tex_info.height + + local animation_data + + local sprite_image_id = gui.get_flipbook(node) + for _, animation in ipairs(atlas_data.animations) do + if hash(animation.id) == sprite_image_id then + animation_data = animation + break + end + end + assert(animation_data, "Unable to find image " .. sprite_image_id) + + local frames = {} + for index = animation_data.frame_start, animation_data.frame_end - 1 do + local uvs = atlas_data.geometries[index].uvs + assert(#uvs == 8, "Sprite trim mode should be disabled for the images.") + + -- UV texture coordinates + -- 1 + -- ^ V + -- | + -- | + -- | U + -- 0-------> 1 + + -- uvs = { + -- 0, 0, + -- 0, height, + -- width, height, + -- width, 0 + -- }, + -- Point indeces (Point number {uv_index_x, uv_index_y}) + -- geometries.indices = {0 (1,2), 1(3,4), 2(5,6), 0(1,2), 2(5,6), 3(7,8)} + -- 1------2 + -- | / | + -- | A / | + -- | / B | + -- | / | + -- 0------3 + + local width = uvs[5] - uvs[1] -- Width of sprite region + local height = uvs[2] - uvs[4] -- Height of sprite region + local is_rotated = height < 0 -- In case of rotated sprite + + local x_left = uvs[1] + local y_bottom = uvs[2] + local x_right = uvs[5] + local y_top = uvs[6] + + -- Okay now it's correct for non rotated + local uv_coord = vmath.vector4( + x_left / tex_w, + (tex_h - y_bottom) / tex_h, + x_right / tex_w, + (tex_h - y_top) / tex_h + ) + + if is_rotated then + -- In case the atlas has clockwise rotated sprite. + -- 0---------------1 + -- | \ A | + -- | \ | + -- | \ | + -- | B \ | + -- 3---------------2 + height = -height + + uv_coord.x, uv_coord.y, uv_coord.z, uv_coord.w = uv_coord.y, uv_coord.z, uv_coord.w, uv_coord.x + + -- Update uv_coord + --uv_coord = vmath.vector4( + -- u1 / tex_w, + -- (tex_h - v2) / tex_h, + -- u2 / tex_w, + -- (tex_h - v1) / tex_h + --) + end + + local frame = { + uv_coord = uv_coord, + w = width, + h = height, + uv_rotated = is_rotated and vmath.vector4(0, 1, 0, 0) or vmath.vector4(1, 0, 0, 0) + } + + table.insert(frames, frame) + end + + return { + frames = frames, + width = animation_data.width, + height = animation_data.height, + fps = animation_data.fps, + v = vmath.vector4(1, 1, animation_data.width, animation_data.height), + current_frame = 1, + node = node, + } +end + + return M diff --git a/druid/materials/gui_repeat/gui_repeat.fp b/druid/materials/gui_repeat/gui_repeat.fp new file mode 100644 index 0000000..fb355aa --- /dev/null +++ b/druid/materials/gui_repeat/gui_repeat.fp @@ -0,0 +1,84 @@ +#version 140 + +uniform sampler2D texture_sampler; + +in vec2 var_texcoord0; +in vec4 var_color; +in vec4 var_uv; +in vec4 var_repeat; // [repeat_x, repeat_y, anchor_x, anchor_y] +in vec4 var_params; // [margin_x, margin_y, offset_x, offset_y] +in vec4 var_perspective; +in vec4 var_uv_rotated; + +out vec4 color_out; + +void main() { + vec2 pivot = var_repeat.zw; + // Margin is a value between 0 and 1 that means offset/padding from the one image to another + vec2 margin = var_params.xy; + vec2 offset = var_params.zw; + vec2 repeat = var_repeat.xy; + + // Atlas UV to local UV [0, 1] + float u = (var_texcoord0.x - var_uv.x) / (var_uv.z - var_uv.x); + float v = (var_texcoord0.y - var_uv.y) / (var_uv.w - var_uv.y); + + // Adjust local UV by the pivot point. So 0:0 will be at the pivot point of node + u = u - (0.5 + pivot.x); + v = v - (0.5 - pivot.y); + + // If rotated, swap UV + if (var_uv_rotated.y < 0.5) { + float temp = u; + u = v; + v = temp; + } + + // Adjust repeat by the margin + repeat.x = repeat.x / (1.0 + margin.x); + repeat.y = repeat.y / (1.0 + margin.y); + + // Repeat is a value between 0 and 1 that represents the number of times the texture is repeated in the atlas. + float tile_u = fract(u * repeat.x); + float tile_v = fract(v * repeat.y); + + float tile_width = 1.0 / repeat.x; + float tile_height = 1.0 / repeat.y; + + // Adjust tile UV by the pivot point. + // Not center is left top corner, need to adjust it to pivot point + tile_u = fract(tile_u + pivot.x + 0.5); + tile_v = fract(tile_v - pivot.y + 0.5); + + // Apply offset + tile_u = fract(tile_u + offset.x); + tile_v = fract(tile_v + offset.y); + + // Extend margins + margin = margin * 0.5; + tile_u = mix(0.0 - margin.x, 1.0 + margin.x, tile_u); + tile_v = mix(0.0 - margin.y, 1.0 + margin.y, tile_v); + float alpha = 0.0; + // If the tile is outside the margins, make it transparent, without IF + alpha = step(0.0, tile_u) * step(tile_u, 1.0) * step(0.0, tile_v) * step(tile_v, 1.0); + + tile_u = clamp(tile_u, 0.0, 1.0); // Keep borders in the range 0-1 + tile_v = clamp(tile_v, 0.0, 1.0); // Keep borders in the range 0-1 + + if (var_uv_rotated.y < 0.5) { + float temp = tile_u; + tile_u = tile_v; + tile_v = temp; + } + + // Remap local UV to the atlas UV + vec2 uv = vec2( + mix(var_uv.x, var_uv.z, tile_u), // Get texture coordinate from the atlas + mix(var_uv.y, var_uv.w, tile_v) // Get texture coordinate from the atlas + //mix(var_uv.x, var_uv.z, tile_u * var_uv_rotated.x + tile_v * var_uv_rotated.z), + //mix(var_uv.y, var_uv.w, 1.0 - (tile_u * var_uv_rotated.y + tile_v * var_uv_rotated.x)) + ); + + lowp vec4 tex = texture(texture_sampler, uv); + color_out = tex * var_color; +} \ No newline at end of file diff --git a/druid/materials/gui_repeat/gui_repeat.material b/druid/materials/gui_repeat/gui_repeat.material new file mode 100644 index 0000000..213467e --- /dev/null +++ b/druid/materials/gui_repeat/gui_repeat.material @@ -0,0 +1,43 @@ +name: "repeat" +tags: "gui" +vertex_program: "/druid/materials/gui_repeat/gui_repeat.vp" +fragment_program: "/druid/materials/gui_repeat/gui_repeat.fp" +vertex_constants { + name: "view_proj" + type: CONSTANT_TYPE_VIEWPROJ +} +vertex_constants { + name: "uv_coord" + type: CONSTANT_TYPE_USER + value { + z: 1.0 + w: 1.0 + } +} +vertex_constants { + name: "uv_repeat" + type: CONSTANT_TYPE_USER + value { + x: 1.0 + y: 1.0 + } +} +vertex_constants { + name: "params" + type: CONSTANT_TYPE_USER + value { + } +} +vertex_constants { + name: "perspective" + type: CONSTANT_TYPE_USER + value { + } +} +vertex_constants { + name: "uv_rotated" + type: CONSTANT_TYPE_USER + value { + x: 1.0 + } +} diff --git a/druid/materials/gui_repeat/gui_repeat.vp b/druid/materials/gui_repeat/gui_repeat.vp new file mode 100644 index 0000000..f50808b --- /dev/null +++ b/druid/materials/gui_repeat/gui_repeat.vp @@ -0,0 +1,55 @@ +#version 140 + +in mediump vec3 position; +in mediump vec2 texcoord0; +in lowp vec4 color; + +uniform vertex_inputs +{ + highp mat4 view_proj; + highp vec4 uv_coord; + highp vec4 uv_repeat; // [repeat_x, repeat_y, pivot_x, pivot_y] + vec4 uv_rotated; + vec4 params; // [margin_x, margin_y, offset_x, offset_y] + vec4 perspective; // [perspective_x, perspective_y, zoom, offset_y] +}; + +out mediump vec2 var_texcoord0; +out lowp vec4 var_color; +out highp vec4 var_uv; +out highp vec4 var_repeat; +out vec4 var_params; +out vec4 var_perspective; +out vec4 var_uv_rotated; + +void main() +{ + var_texcoord0 = texcoord0; + var_color = vec4(color.rgb * color.a, color.a); + var_uv = uv_coord; + var_repeat = uv_repeat; + var_params = params; + var_perspective = perspective; + var_uv_rotated = uv_rotated; + + float perspective_y = position.z; + + float scale_x = 1.0 - abs(perspective.x); + float scale_y = 1.0 - abs(perspective_y); + + mat4 transform = mat4( + scale_x, 0, 0, perspective.z, + 0, scale_y, 0, perspective.w, + 0, 0, 1, 0, + perspective.x, perspective_y, 0, 1.0 + ); + + // Matrix Info = mat4( + // scale_x, skew_x, 0, offset_x, + // skew_y, scale_y, 0, offset_y, + // 0, 0, scale_z, offset_z, + // perspective_x, perspective_y, perspective_z, zoom + //) + + gl_Position = view_proj * vec4(position.xyz, 1.0) * transform; +} diff --git a/druid/materials/skew/gui_skew.fp b/druid/materials/skew/gui_skew.fp new file mode 100644 index 0000000..c3d593d --- /dev/null +++ b/druid/materials/skew/gui_skew.fp @@ -0,0 +1,18 @@ +#version 140 + +uniform sampler2D texture_sampler; + +in vec2 var_texcoord0; +in vec4 var_color; + +out vec4 color_out; + +void main() { + lowp vec4 tex = texture(texture_sampler, var_texcoord0.xy); + if (tex.a < 0.5) { + discard; + } + + // Final color of stencil texture + color_out = tex * var_color; +} \ No newline at end of file diff --git a/druid/materials/skew/gui_skew.material b/druid/materials/skew/gui_skew.material new file mode 100644 index 0000000..5575ae1 --- /dev/null +++ b/druid/materials/skew/gui_skew.material @@ -0,0 +1,8 @@ +name: "repeat" +tags: "gui" +vertex_program: "/druid/materials/stencil/gui_stencil.vp" +fragment_program: "/druid/materials/stencil/gui_stencil.fp" +vertex_constants { + name: "view_proj" + type: CONSTANT_TYPE_VIEWPROJ +} diff --git a/druid/materials/skew/gui_skew.vp b/druid/materials/skew/gui_skew.vp new file mode 100644 index 0000000..382c88b --- /dev/null +++ b/druid/materials/skew/gui_skew.vp @@ -0,0 +1,20 @@ +#version 140 + +uniform vertex_inputs { + highp mat4 view_proj; +}; + +// positions are in world space +in mediump vec3 position; +in mediump vec2 texcoord0; +in lowp vec4 color; + +out mediump vec2 var_texcoord0; +out lowp vec4 var_color; + +void main() +{ + var_texcoord0 = texcoord0; + var_color = vec4(color.rgb * color.a, color.a); + gl_Position = view_proj * vec4(position.xyz, 1.0); +} diff --git a/druid/widget/node_repeat/node_repeat.lua b/druid/widget/node_repeat/node_repeat.lua new file mode 100644 index 0000000..353517d --- /dev/null +++ b/druid/widget/node_repeat/node_repeat.lua @@ -0,0 +1,195 @@ +local helper = require("druid.helper") +local event_queue = require("druid.event_queue") + +---@class druid.node_repeat: druid.widget +---@field animation table +---@field node node +---@field params vector4 +---@field time number +local M = {} + +function M:init(node) + self.node = self:get_node(node) + self.animation = nil + gui.set_material(self.node, hash("gui_repeat")) + self.time = 0 + self.margin = 0 + + self.params = gui.get(self.node, "params") --[[@as vector4]] + self:get_atlas_path(function(atlas_path) + self.is_inited = self:init_tiling_animation(atlas_path) + local repeat_x, repeat_y = self:get_repeat() + self:animate(repeat_x, repeat_y) + end) + + --self.druid.events.on_node_property_changed:subscribe(self.on_node_property_changed, self) +end + + +function M:on_node_property_changed(node, property) + if not self.is_inited or node ~= self.node then + return + end + + if property == "size" or property == "scale" then + local repeat_x, repeat_y = self:get_repeat() + self:set_repeat(repeat_x, repeat_y) + end +end + + +function M:get_repeat() + if not self.is_inited then + return 1, 1 + end + local size_x = gui.get(self.node, helper.PROP_SIZE_X) + local size_y = gui.get(self.node, helper.PROP_SIZE_Y) + local scale_x = gui.get(self.node, helper.PROP_SCALE_X) + local scale_y = gui.get(self.node, helper.PROP_SCALE_Y) + + local repeat_x = (size_x / self.animation.width) / scale_x + local repeat_y = (size_y / self.animation.height) / scale_y + + return repeat_x, repeat_y +end + + +function M:get_atlas_path(callback) + event_queue.request("druid.get_atlas_path", callback, gui.get_texture(self.node), msg.url()) +end + + +---@return boolean +function M:init_tiling_animation(atlas_path) + if not atlas_path then + print("No atlas path found for node", gui.get_id(self.node), gui.get_texture(self.node)) + print("Probably you should add druid.script at window collection to access resources") + return false + end + + self.animation = helper.get_animation_data_from_node(self.node, atlas_path) + return true +end + +-- Start our repeat shader work +-- @param repeat_x -- X factor +-- @param repeat_y -- Y factor +function M:animate(repeat_x, repeat_y) + if not self.is_inited then + return + end + + local node = self.node + local animation = self.animation + + local frame = animation.frames[1] + gui.set(node, "uv_coord", frame.uv_coord) + self:set_repeat(repeat_x, repeat_y) + + if #animation.frames > 1 and animation.fps > 0 then + animation.handle = + timer.delay(1/animation.fps, true, function(self, handle, time_elapsed) + local next_rame = animation.frames[animation.current_frame] + gui.set(node, "uv_coord", next_rame.uv_coord) + + animation.current_frame = animation.current_frame + 1 + if animation.current_frame > #animation.frames then + animation.current_frame = 1 + end + end) + end +end + + +function M:final() + local animation = self.animation + if animation.handle then + timer.cancel(animation.handle) + animation.handle = nil + end +end + + +-- Update repeat factor values +-- @param repeat_x +-- @param repeat_y +function M:set_repeat(repeat_x, repeat_y) + local animation = self.animation + animation.v.x = repeat_x or animation.v.x + animation.v.y = repeat_y or animation.v.y + + local anchor = helper.get_pivot_offset(gui.get_pivot(self.node)) + animation.v.z = anchor.x + animation.v.w = anchor.y + + gui.set(self.node, "uv_repeat", animation.v) +end + + +function M:set_perpective(perspective_x, perspective_y) + if perspective_x then + gui.set(self.node, "perspective.x", perspective_x) + end + + if perspective_y then + gui.set(self.node, "perspective.y", perspective_y) + end + + return self +end + + +function M:set_perpective_offset(offset_x, offset_y) + if offset_x then + gui.set(self.node, "perspective.z", offset_x) + end + + if offset_y then + gui.set(self.node, "perspective.w", offset_y) + end + + return self +end + + +function M:set_offset(offset_perc_x, offset_perc_y) + self.params.z = offset_perc_x or self.params.z + self.params.w = offset_perc_y or self.params.w + gui.set(self.node, "params", self.params) + return self +end + + +function M:set_margin(margin_x, margin_y) + self.params.x = margin_x or self.params.x + self.params.y = margin_y or self.params.y + gui.set(self.node, "params", self.params) + return self +end + + +---@param scale number +function M:set_scale(scale) + local current_scale_x = gui.get(self.node, helper.PROP_SCALE_X) + local current_scale_y = gui.get(self.node, helper.PROP_SCALE_Y) + local current_size_x = gui.get(self.node, helper.PROP_SIZE_X) + local current_size_y = gui.get(self.node, helper.PROP_SIZE_Y) + + local delta_scale_x = scale / current_scale_x + local delta_scale_y = scale / current_scale_y + gui.set(self.node, helper.PROP_SCALE_X, scale) + gui.set(self.node, helper.PROP_SCALE_Y, scale) + gui.set(self.node, helper.PROP_SIZE_X, current_size_x / delta_scale_x) + gui.set(self.node, helper.PROP_SIZE_Y, current_size_y / delta_scale_y) + + --self.druid:on_node_property_changed(self.node, "scale") + --self.druid:on_node_property_changed(self.node, "size") + + --local repeat_x, repeat_y = self:get_repeat() + --self:set_repeat(repeat_x, repeat_y) + + return self +end + + +return M diff --git a/druid/widget/properties_panel/properties/property_slider.lua b/druid/widget/properties_panel/properties/property_slider.lua index d025989..844cb94 100644 --- a/druid/widget/properties_panel/properties/property_slider.lua +++ b/druid/widget/properties_panel/properties/property_slider.lua @@ -19,6 +19,7 @@ function M:init() self.min = 0 self.max = 1 + self.step = 0.01 self.text_name = self.druid:new_text("text_name") :set_text_adjust("scale_then_trim", 0.3) @@ -63,7 +64,7 @@ end ---@param value number function M:set_value(value, is_instant) local diff = math.abs(self.max - self.min) - self.slider:set(value / diff, true) + self.slider:set((value - self.min) / diff, true) local is_changed = self._value ~= value if not is_changed then