Join the RoClicks Discord
RoClicks

Picking.luau

Luau, 166 lines from Building overview

Download Picking.luau
--!strict
-- Picking (client): correct pointer rays, a forgiving pick, one reused hover Highlight, and tap-vs-drag.
--
-- THE 58 PX BUG: InputObject.Position is in GUI space (top-bar inset already removed) and must go to
-- Camera:ScreenPointToRay. UserInputService:GetMouseLocation() is in viewport space (inset included) and
-- must go to Camera:ViewportPointToRay. Mixing them aims one inset (58 px in a Studio playtest) above the
-- cursor: clicks pick the building above, or nothing. Never add/subtract GetGuiInset by hand.
--
--   Picking.ownerAt = function(cell) return myGrid:owner(cell) end   -- your cell -> id lookup
--   Picking.modelOf = function(id) return buildingsById[id] end
--   Picking.groundFolder = workspace.Map.Ground
--   Picking.onTap = function(id, model) openBubble(id) end
--   Picking.start()

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local Picking = {}
Picking.CELL = 4 -- studs per grid cell
Picking.ownerAt = function(_cell: Vector2): string? return nil end
Picking.modelOf = function(_id: string): Model? return nil end
Picking.groundFolder = nil :: Instance?
Picking.onTap = function(_id: string?, _model: Model?) end
Picking.blocked = function(): boolean return false end -- placing, a screen open, cutscene...

-- tap thresholds: movement in px, max duration in s
Picking.TAP = {
	mouse = { move = 7, time = 0.5 },
	touch = { move = 14, frac = 0.025, time = 0.65 }, -- 14 px or 2.5% of the short screen side
}

---------------------------------------------------------------- rays
function Picking.rayFromInput(pos: Vector3 | Vector2): Ray -- InputObject.Position (GUI space)
	return workspace.CurrentCamera:ScreenPointToRay(pos.X, pos.Y)
end

function Picking.rayFromMouse(): Ray -- GetMouseLocation (viewport space)
	local m = UserInputService:GetMouseLocation()
	return workspace.CurrentCamera:ViewportPointToRay(m.X, m.Y)
end

function Picking.overGui(guiPos: Vector2): boolean -- GetGuiObjectsAtPosition takes GUI space too
	local pg = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
	if not pg then return false end
	for _, g in pg:GetGuiObjectsAtPosition(guiPos.X, guiPos.Y) do
		if g.Visible and (g:IsA("GuiButton") or (g:IsA("Frame") and g.BackgroundTransparency < 1)) then
			return true
		end
	end
	return false
end

---------------------------------------------------------------- forgiving pick
-- Cast against the GROUND only, turn the hit into a cell, ask who owns the cell. Works whatever CanQuery
-- says on decorative parts, and the hit area is the whole footprint, not the thin parts of a model.
function Picking.pick(ray: Ray): (string?, Model?)
	local params = RaycastParams.new()
	if Picking.groundFolder then
		params.FilterType = Enum.RaycastFilterType.Include
		params.FilterDescendantsInstances = { Picking.groundFolder }
	end
	local hit = workspace:Raycast(ray.Origin, ray.Direction * 2000, params)
	if not hit then return nil, nil end
	local cell = Vector2.new(math.floor(hit.Position.X / Picking.CELL), math.floor(hit.Position.Z / Picking.CELL))
	local id = Picking.ownerAt(cell)
	return id, if id then Picking.modelOf(id) else nil
end

---------------------------------------------------------------- hover (ONE Highlight, reused)
-- Roblox draws at most 31 Highlights at once; one per building silently stops drawing past that.
local hover = Instance.new("Highlight")
hover.Name = "HoverHighlight"
hover.FillColor = Color3.new(1, 1, 1)
hover.OutlineColor = Color3.new(1, 1, 1)
hover.FillTransparency = 1
hover.OutlineTransparency = 1
hover.DepthMode = Enum.HighlightDepthMode.Occluded
hover.Enabled = false

local hoverId: string? = nil
local lastMouse, lastCam = Vector2.zero, CFrame.identity
local lastPick = 0

local function setHover(id: string?, model: Model?)
	if id == hoverId then return end
	hoverId = id
	if model then
		hover.Adornee = model
		hover.Enabled = true
		hover.FillTransparency, hover.OutlineTransparency = 0.86, 0.3 -- tween these in ~0.1 s in a real game
	else
		hover.Enabled = false
		hover.Adornee = nil
	end
end

local function updateHover()
	local touchOnly = UserInputService.TouchEnabled and not UserInputService.MouseEnabled
	if touchOnly or Picking.blocked() or UserInputService.MouseBehavior ~= Enum.MouseBehavior.Default then
		setHover(nil, nil)
		return
	end
	local m, cam = UserInputService:GetMouseLocation(), workspace.CurrentCamera.CFrame
	-- re-pick only when the mouse or camera moved, at most 15 times a second
	if (m == lastMouse and cam == lastCam) or os.clock() - lastPick < 1 / 15 then return end
	lastMouse, lastCam, lastPick = m, cam, os.clock()
	local inset = (game:GetService("GuiService"):GetGuiInset())
	if Picking.overGui(m - inset) then -- convert viewport -> GUI space for the GUI query only
		setHover(nil, nil)
		return
	end
	setHover(Picking.pick(Picking.rayFromMouse())) -- the SAME pick a click uses
end

---------------------------------------------------------------- tap vs drag
-- Note: UserInputService:GetTouchesInProgress does not exist. Count touches yourself.
type Press = { pos: Vector2, t: number, kind: string, multi: boolean }
local touches = 0
local press: Press? = nil

local function isTap(p: Press, input: InputObject): boolean
	local kind = p.kind
	local rule = Picking.TAP[kind]
	local cam = workspace.CurrentCamera.ViewportSize
	local limit = if kind == "touch"
		then math.max(rule.move, (rule.frac or 0) * math.min(cam.X, cam.Y))
		else rule.move
	local moved = (Vector2.new(input.Position.X, input.Position.Y) - p.pos).Magnitude
	return moved <= limit and os.clock() - p.t <= rule.time and not p.multi
end

function Picking.start()
	hover.Parent = workspace
	RunService.RenderStepped:Connect(updateHover)

	UserInputService.InputBegan:Connect(function(input, processed)
		local t = input.UserInputType
		if t == Enum.UserInputType.Touch then
			touches += 1
			if press then press.multi = true end -- a second finger = pinch/pan, never a tap
		end
		if processed or Picking.blocked() then return end
		if t == Enum.UserInputType.MouseButton1 or t == Enum.UserInputType.Touch then
			if not press then
				press = { pos = Vector2.new(input.Position.X, input.Position.Y), t = os.clock(),
					kind = if t == Enum.UserInputType.Touch then "touch" else "mouse", multi = touches > 1 }
			end
		end
	end)

	UserInputService.InputEnded:Connect(function(input)
		local t = input.UserInputType
		if t == Enum.UserInputType.Touch then touches = math.max(0, touches - 1) end
		if t ~= Enum.UserInputType.MouseButton1 and t ~= Enum.UserInputType.Touch then return end
		local p = press
		if touches > 0 and t == Enum.UserInputType.Touch then return end -- wait for the last finger
		press = nil
		if p and isTap(p, input) then
			Picking.onTap(Picking.pick(Picking.rayFromInput(input.Position))) -- GUI space -> ScreenPointToRay
		end
	end)
end

return Picking