Selection Box from Sprite or GUI

Hello, everyone!

I joined Defold a few weeks ago and have been migrating my RTS game to it. Along the way, I’ve faced many challenges—one of them was creating an on-screen selection box for selecting units.

After doing some research on Sprites and GUI, and with the help of Koen “Kaji” Bollen from the Defold Discord server, I learned that I could use either a 1x1 transparent Sprite or a simple box in the GUI. Creating the 1x1 pixel sprite was easy, but I wanted a hollow box with an outline rather than a solid one.

That turned out to be a big challenge! In the GUI, it was quick and straightforward, but with Sprites, it was much harder because Slice-9 scaling didn’t behave the same way as it does in the GUI. I had to find another approach, which took some time.

But in the end, I figured it out! :tada: So I wanted to share my solution with all of you.

TL;DR: Below is a script for creating a selection box with an outline using GUI or Sprite.

GUI Settings to use:


PNG to Use:
Selection Box_gui_9slice
GUI Script is as:

-- selection.gui_script
local start_pos = vmath.vector3()
local dragging = false
local box_node

function init(self)
	box_node = gui.get_node("selection_rect")
	-- Set slice9 values to create a border
	gui.set_slice9(box_node, vmath.vector4(2, 2, 2, 2))
	msg.post(".", "acquire_input_focus")
	gui.set_enabled(box_node, false)
end

function on_input(self, action_id, action)
	if action_id == hash("touch") then
		if action.pressed and not dragging then
			-- Start dragging
			start_pos.x = action.x
			start_pos.y = action.y
			dragging = true
			gui.set_enabled(box_node, true)
			gui.set_position(box_node, start_pos)
			gui.set_size(box_node, vmath.vector3(0, 0, 0))
		elseif action.released and dragging then
			-- Stop dragging
			dragging = false
			gui.set_enabled(box_node, false)
		elseif dragging then
			-- Update box size/position
			local current_x = action.x
			local current_y = action.y
			local min_x = math.min(start_pos.x, current_x)
			local min_y = math.min(start_pos.y, current_y)
			local width = math.abs(current_x - start_pos.x)
			local height = math.abs(current_y - start_pos.y)

			gui.set_position(box_node, vmath.vector3(min_x, min_y, 0))
			gui.set_size(box_node, vmath.vector3(width, height, 0))
		end
	end
end

There are three versions of the Sprite Selection Box because I preferred using this over the GUI version. You can mix and match them based on your preferences—all of them work!

There’s also a fourth version (v4), where I converted v3 into a Lua script. However, since it only took 1–2 minutes to make, I didn’t include it. But if anyone needs it, I’ll be happy to add it! :blush:

Sprite Settings to use:

Sprite PNG to use:
Selection Box_9slice

Sprite v1 code. The one it worked with:

local DISPLAY_WIDTH = sys.get_config_int("display.width")
local DISPLAY_HEIGHT = sys.get_config_int("display.height")

local start_pos = vmath.vector3()
local dragging = false
local selection_box

function init(self)
    msg.post(".", "acquire_input_focus")
    selection_box = msg.url("#selection_box")
    msg.post(selection_box, "disable")
end

local function screen_to_world(x, y, z, camera_id)
    local projection = camera.get_projection(camera_id)
    local view = camera.get_view(camera_id)
    local w, h = window.get_size()
    w = w / (w / DISPLAY_WIDTH)
    h = h / (h / DISPLAY_HEIGHT)

    local inv = vmath.inv(projection * view)
    x = (2 * x / w) - 1
    y = (2 * y / h) - 1
    z = (2 * z) - 1
    local x1 = x * inv.m00 + y * inv.m01 + z * inv.m02 + inv.m03
    local y1 = x * inv.m10 + y * inv.m11 + z * inv.m12 + inv.m13
    local z1 = x * inv.m20 + y * inv.m21 + z * inv.m22 + inv.m23
    return x1, y1, z1
end

function on_input(self, action_id, action)
    if action_id == hash("touch") then
        if action.pressed and not dragging then
            local worldx, worldy = screen_to_world(action.x, action.y, 0, "RTSCamera#camera_component")
            start_pos = vmath.vector3(worldx, worldy, 0)
            dragging = true
            go.set_position(start_pos, selection_box)
            go.set(selection_box, "size", vmath.vector3(0.1, 0.1, 0))
            msg.post(selection_box, "enable")
        elseif action.released and dragging then
            dragging = false
            msg.post(selection_box, "disable")
        elseif dragging then
            local worldx, worldy = screen_to_world(action.x, action.y, 0, "RTSCamera#camera_component")
            local current_pos = vmath.vector3(worldx, worldy, 0)

            local min_x = math.min(start_pos.x, current_pos.x)
            local min_y = math.min(start_pos.y, current_pos.y)
            local max_x = math.max(start_pos.x, current_pos.x)
            local max_y = math.max(start_pos.y, current_pos.y)

            local center = vmath.vector3(
                (min_x + max_x) / 2,
                (min_y + max_y) / 2,
                0
            )

            local width = math.max(math.abs(max_x - min_x), 0.1)
            local height = math.max(math.abs(max_y - min_y), 0.1)
            go.set_position(center, selection_box)
            go.set(selection_box, "size", vmath.vector3(width, height, 0))
        end
    end
end

Version 2 (v2) is cleaner, well-commented, and easier to understand, with a slight focus on performance.

local DISPLAY_WIDTH = sys.get_config_int("display.width")
local DISPLAY_HEIGHT = sys.get_config_int("display.height")

local start_pos = vmath.vector3()
local dragging = false
local selection_box

-- Camera Variables Related to Screen_On_World
local rts_camera = nil
local rts_camera_projection = nil
local rts_camera_view = nil
local rts_camera_inv
local rts_camera_w, rts_camera_h = nil, nil
local prev_worldx, prev_worldy = nil, nil


function init(self)
	msg.post(".", "acquire_input_focus")
	selection_box = msg.url("#selection_box")
	msg.post(selection_box, "disable") -- Start disabled

	-- screen_to_world function variables
	rts_camera = msg.url("RTSCamera#camera_component") -- Gets the Camera Component
	-- rts_camera_w, rts_camera_h = window.get_size()  -- Gets Screen Size
	rts_camera_w = DISPLAY_WIDTH
	rts_camera_h = DISPLAY_HEIGHT
end

-- Custom screen-to-world conversion function
local function screen_to_world(x, y)
	-- Gets the inversion which is updated when first pressed
	local inv = rts_camera_inv

	-- Convert screen coordinates to the [-1, 1] range
	x = (2 * x / rts_camera_w) - 1 -- Z Ranges From [-1 (left) to 1 (right)]
	y = (2 * y / rts_camera_h) - 1 -- Y Ranges From [-1 (bottom) to 1 (top)]

	-- Gets the Coordinates by Reversing Camera’s projection and View transformations
	local x1 = x * inv.m00 + y * inv.m01 + inv.m03 -- Gets in X
	local y1 = x * inv.m10 + y * inv.m11 + inv.m13 -- Gets in Y
	return x1, y1                               -- Returns X, Y Coordinates
end

local function selection_update_start(action)                        -- Runs once when pressed
	-- Updates Screen_to_World Variables
	rts_camera_projection = camera.get_projection(rts_camera)        -- Gets the Projection Size
	rts_camera_view = camera.get_view(rts_camera)                    -- Gets the View Size
	rts_camera_inv = vmath.inv(rts_camera_projection * rts_camera_view) -- Gets the Inversion

	-- Sets Starting Position and Dragging
	local worldx, worldy = screen_to_world(action.x, action.y) -- Gets Global Position from Screen
	start_pos = vmath.vector3(worldx, worldy, 0)            -- Sets the Start Position
	dragging = true                                         -- Dragging True

	-- Update Sprite Properties
	go.set_position(start_pos, selection_box)              -- Apply Position
	go.set(selection_box, "size", vmath.vector3(0.1, 0.1, 0)) -- Size(0.1) in X, Y
	msg.post(selection_box, "enable")                      -- Sprite Visiable
end

local function selection_update_dragging(current_pos) -- Runs when Dragging
	-- Gets the Min, Max of X and Y
	local min_x = math.min(start_pos.x, current_pos.x) -- Min X
	local min_y = math.min(start_pos.y, current_pos.y) -- Min Y
	local max_x = math.max(start_pos.x, current_pos.x) -- Max X
	local max_y = math.max(start_pos.y, current_pos.y) -- Max Y

	-- Calculates the center of the selection box
	local center = vmath.vector3(
		(min_x + max_x) / 2, -- X Direction
		(min_y + max_y) / 2, -- Y Direction
		1              -- Z Direction
	)

	-- Calculate width/height of the selection box from drag distance
	-- Ensures a minimum size of 0.1 to prevent the sprite from collapsing
	local width = math.max(math.abs(max_x - min_x), 0.1) -- Abs - Makes Negative Values to Positive.
	local height = math.max(math.abs(max_y - min_y), 0.1) -- Max - Ensures width is atleast (0.1)

	-- Apply size instead of scale
	go.set_position(center, selection_box)                      -- Applies Position
	go.set(selection_box, "size", vmath.vector3(width, height, 0)) -- Sets size based on Width, Height
end

function on_input(self, action_id, action) -- When a input is received
	if action_id == hash("touch") then     -- When action received is "touch"
		if action.pressed and not dragging then
			selection_update_start(action) -- Applies Sprite Properties at Start
		elseif action.released and dragging then
			dragging = false               -- Dragging False
			msg.post(selection_box, "disable") -- Hides Sprite
		elseif dragging then
			-- Updates Screen_to_World Variables
			-- THIS IS ONLY NEEDED IF YOU WANT TO USE SELECTION WHILE PANNING CAMERA
			rts_camera_projection = camera.get_projection(rts_camera)  -- Gets the Projection Size
			rts_camera_view = camera.get_view(rts_camera)              -- Gets the View Size
			rts_camera_inv = vmath.inv(rts_camera_projection * rts_camera_view) -- Gets the Inversion

			local worldx, worldy = screen_to_world(action.x, action.y) -- Gets Global Position from Screen
			if prev_worldx ~= worldx or prev_worldy ~= worldy then
				local current_pos = vmath.vector3(worldx, worldy, 0)   -- Stores Current Position
				selection_update_dragging(current_pos)                 -- Applies Sprite Properties during dragging
				prev_worldx = worldx                                   -- Updates prev_worldx
				prev_worldy = worldy                                   -- Updates prev_worldy
			end
		end
	end
end

Version 3 (v3) is my personal favorite! It’s fully commented and designed with performance, readability, and ease of use in mind.

-- Control_Manager.script

-- Input Variables
local dragging = false
local selection_box

-- Camera Variables
local rts_camera
local rts_camera_size
local inv_matrix -- inverse matrix cached on drag start

-- Position Variables
local start_pos = vmath.vector3()
local prev_pos = vmath.vector3()

function init(self)
	msg.post(".", "acquire_input_focus")            -- Activates to get inputs
	selection_box = msg.url("#selection_box")       -- Selection Box - Sprite Component
	msg.post(selection_box, "disable")              -- Sprite Hidden
	rts_camera = msg.url("RTSCamera#camera_component") -- Camera Component

	-- Gets Display Properties from System
	local DISPLAY_WIDTH = sys.get_config_int("display.width")
	local DISPLAY_HEIGHT = sys.get_config_int("display.height")

	-- Get Screen To World Properties
	rts_camera_size = vmath.vector3(DISPLAY_WIDTH, DISPLAY_HEIGHT, 0)
end

local function screen_to_world(x, y)
	-- Convert screen coordinates to the [-1, 1] range (Normalized Device Coordinates)
	local ndc_x = (x / rts_camera_size.x) * 2 - 1 -- X Ranges From [-1 (left) to 1 (right)]
	local ndc_y = (y / rts_camera_size.y) * 2 - 1 -- Y Ranges From [-1 (bottom) to 1 (top)]

	-- Applies the inverse camera transformation to convert NDC to world coordinates.
	local world = inv_matrix * vmath.vector4(ndc_x, ndc_y, 0, 1) -- NDC position as a 4D vector
	-- Converts homogeneous coordinates (x, y, z, w) to 3D world coordinates
	-- No effect for orthographic(2D). Depth Scaling For perspective cameras.
	return world.x / world.w, world.y / world.w -- Return X, Y
end

local function selection_update_start(action)         -- Runs once when pressed
	-- Updates Screen_to_World Variables
	local projection = camera.get_projection(rts_camera) -- Gets the Projection Size
	local view = camera.get_view(rts_camera)          -- Gets the View Size
	inv_matrix = vmath.inv(projection * view)         -- Gets the Inversion

	-- Initialize positions
	local x, y = screen_to_world(action.x, action.y) -- Gets Global Position
	start_pos.x, start_pos.y = x, y               -- Sets start_pos
	prev_pos.x, prev_pos.y = x, y                 -- Updates prev_pos

	-- Update Sprite Properties
	go.set_position(start_pos, selection_box)              -- Set Position
	go.set(selection_box, "size", vmath.vector3(0.1, 0.1, 0)) -- Set Size(0.1) in X, Y
	msg.post(selection_box, "enable")                      -- Sprite Visable
	dragging = true                                        -- Dragging True
end

local function selection_update_dragging(x, y) -- Runs when Dragging
	-- Calculate bounding box
	local min_x = math.min(start_pos.x, x)     -- Min X
	local min_y = math.min(start_pos.y, y)     -- Min Y
	local max_x = math.max(start_pos.x, x)     -- Max X
	local max_y = math.max(start_pos.y, y)     -- Max Y

	-- Calculates the center of the selection box
	local center = vmath.vector3(
		(min_x + max_x) * 0.5, -- X Direction
		(min_y + max_y) * 0.5, -- Y Direction
		0                -- Z Direction
	)

	-- Calculate width/height of the selection box from drag distance
	-- Ensures a minimum size of 0.1 to prevent the sprite from collapsing
	local size = vmath.vector3(
		math.max(max_x - min_x, 0.1), -- Width
		math.max(max_y - min_y, 0.1), -- Height
		0                       -- Z Direction
	)

	-- Set sprite properties
	go.set_position(center, selection_box) -- Sets Position
	go.set(selection_box, "size", size) -- Sets Size
end

function on_input(self, action_id, action)
	if action_id ~= hash("touch") then return end

	if action.pressed and not dragging then
		selection_update_start(action)             -- Applies Sprite Properties at Start
	elseif action.released and dragging then
		dragging = false                           -- Dragging False
		msg.post(selection_box, "disable")         -- Sprite Hidden
	elseif dragging then
		local x, y = screen_to_world(action.x, action.y) -- Gets Global Position

		-- Only update if position changed
		if math.abs(x - prev_pos.x) > 0.001 or math.abs(y - prev_pos.y) > 0.001 then
			prev_pos.x, prev_pos.y = x, y -- Updates prev_pos

			selection_update_dragging(x, y) -- Applies Sprite Properties during dragging
		end
	end
end

If you have any questions, feel free to ask below! :slightly_smiling_face:

5 Likes

Welcome!

It didn’t? What was different?

Thanks for sharing!

Hello! For the GUI you can set it with gui.set_size(). For the Sprite, It did not have a go.set_size(). It only had a go.set_scale(). So i spent a lot of time using that until i realized that i had to use something like go.set(self.selection_box, “size”, vmath.vector3()) to change the size. I had come from Godot few weeks ago so i didn’t focus on the fact that they were different properties because Godot only had Scale.

1 Like