В стандарте, для Роблокса, принято водить игрока за ручку, т.е. по стрелке.

Примитивная реализация этого — Beam из игрока к цели.
Но для RPG данный вариант не подходит. Нам нужна взрослая реализация, а именно по координатам и компасу. Что-то на подобии вот этого:

Для начала нам нужна сама координатная линейка типа такой:

Достаточной длинны, чтобы её можно было смещать в обе стороны относительно центра.
За нулевой вектор направления берём ось Z и направление на север.
И создаём такую структуру в StarterGui:

Чтобы у нас на экране получилось такое:

NavigateAdd это приклеенная копия Navigate (линейка) справа от неё. А у NavFrame мы выставили ClipDescendans в истину, чтобы не отображалось всё что выходит за рамки фрейма. Ну и для градиента UIGradient выставляем ZIndex побольше и параметр для Transparency:

для затемнения по краям.
Код же будет примитивен до безобразия:
-- Навигационный модуль
local workspace = game:GetService("Workspace")
local camera = workspace.CurrentCamera
local player = game.Players.LocalPlayer
-- компас
local nav = script.Parent.All.NavFrame.Navigate -- картинка с компасом
local pos = 0 -- текущее направление
-- позиция
local navPos = script.Parent.All.PosFrame.Coordinate -- вывод квадранта
local kvadrant = 20 -- размер квадранта
while task.wait() do
-- обслуживание компаса для навигации
local look = camera.CFrame.LookVector
local angle = math.deg(math.atan2(look.X, -look.Z))
pos = angle / 360 + 0.5
nav.Position = UDim2.new(pos, 0, 0.5, 0)
-- обслуживание позиции персонажа
local character = player.Character
if character then
local hrp = character:FindFirstChild("HumanoidRootPart")
if hrp then
local pos = hrp.Position
local x = math.floor(pos.X / kvadrant)
local z = math.floor(-pos.Z / kvadrant) -- север с отрицательными значениями
navPos.Text = x .. " x " .. z
end
end
end
Что касается квадрантов. Показывать позицию по каждому смещению как то не гоже и потому мы делим плоскость на квадратики. И вот уже эти квадратики и будут нашими координатами.
Ну и получаем нечто такое:
Первичная навигация готова. Работает на стороне клиента, да ещё и сама по себе.
Переходим ко второму шагу — добавление маркеров цели. Сразу скажу, что указателей на объекты на карте не нужно использовать — при некотором удалении объекты на стороне клиента не будут прогружены, хотя на сервере они и будут присутствовать. Поэтому используем обычные Vector3Value с координатами цели. А в качестве меток будут ImageLabel превращённые в квадраты с белыми иконками на прозрачном фоне.
Накидываем во фрейм компаса наши цели (с координатами того, что набросали на плейсе):


И меняем код (ага, тут я уже подключил ИИ, т.к. было лень расчётами заниматься):
--[[ Навигационный модуль
Отображеине линейки компаса относительно направления камеры и отображение позиции игрока на карте
PosFrame - Отображение текущей координаты (квадранта)
NavFrame - Отображение компаса
TargetMarker - новый маркер для указания цели в нём и задаётся цвет маркера (первично белые) и сама иконки
TargetPos - в нём указывает координаты цели на карте
]]
local workspace = game:GetService("Workspace")
local camera = workspace.CurrentCamera
local player = game.Players.LocalPlayer
-- компас
local navFrame = script.Parent.All.NavFrame
local nav = navFrame.Navigate -- картинка с компасом
local pos = 0 -- текущее направление
-- позиция
local navPos = script.Parent.All.PosFrame.Coordinate -- вывод квадранта
local kvadrant = 30 -- размер квадранта
-- НАСТРОЙКИ ДЛЯ ЦЕЛЕЙ
local HIDE_ON_BORDER = false -- true = скрывать маркер на краю; false = задерживать (прилипать) к краю
local MIN_BORDER = 0.05 -- левая граница фрейма (в Scale), где маркер останавливается/исчезает
local MAX_BORDER = 0.95 -- правая граница фрейма (в Scale)
local MAX_DISTANCE = 100 -- расстояние (в studs), при котором маркер уменьшается до минимального размера
local MAX_SIZE_SCALE = 1.0 -- 100% размера, когда игрок находится вплотную к цели
local MIN_SIZE_SCALE = 0.5 -- 25% размера, когда игрок на расстоянии MAX_DISTANCE или дальше
local BASE_SIZE_Y = 0.8 -- базовый размер маркера по высоте относительно NavFrame (в Scale)
-- нам не нужны привязки к тикам или рендеренгу
while task.wait(.01) do
-- обслуживание компаса для навигации
local look = camera.CFrame.LookVector
local cameraAngle = math.deg(math.atan2(look.X, -look.Z))
--if cameraAngle < 0 then cameraAngle = cameraAngle + 360 end
pos = cameraAngle / 360 + 0.5
nav.Position = UDim2.new(pos, 0, 0.5, 0)
-- обслуживание позиции персонажа и маркеров целей
local character = player.Character
if character then
local hrp = character:FindFirstChild("HumanoidRootPart")
if hrp then
local playerPos = hrp.Position
-- Координаты квадранта
local x = math.floor((kvadrant/2 + playerPos.X) / kvadrant)
local z = math.floor((kvadrant/2 + -playerPos.Z) / kvadrant)
navPos.Text = x .. ", " .. z
-- ОБРАБОТКА ВСЕХ ЦЕЛЕЙ С ИМЕНЕМ TargetMarker
for _, child in ipairs(navFrame:GetChildren()) do
if child.Name == "TargetMarker" and child:IsA("GuiObject") then
local vectorValue = child:FindFirstChild("TargetPos")
if vectorValue and vectorValue:IsA("Vector3Value") then
local targetPos = vectorValue.Value
-- 1. Считаем расстояние до цели и настраиваем размер (Scale)
local distance = (targetPos - playerPos).Magnitude
-- Рассчитываем множитель размера от 0.25 до 1.0 на основе дистанции
local sizeClamp = math.clamp(1 - (distance / MAX_DISTANCE), 0, 1)
local currentScale = MIN_SIZE_SCALE + (sizeClamp * (MAX_SIZE_SCALE - MIN_SIZE_SCALE))
-- Применяем размер. За счет UIAspectRatioConstraint ширина подстроится сама
local finalSizeY = BASE_SIZE_Y * currentScale
child.Size = UDim2.new(finalSizeY, 0, finalSizeY, 0)
child.ImageTransparency = BASE_SIZE_Y - finalSizeY
-- 2. Считаем положение на компасе (Математика встроена напрямую)
local direction = targetPos - playerPos
local targetWorldAngle = math.deg(math.atan2(direction.X, -direction.Z))
if targetWorldAngle < 0 then
targetWorldAngle = targetWorldAngle + 360
end
-- Относительный угол между взглядом камеры и целью (-180 до 180)
local relativeAngle = targetWorldAngle - cameraAngle
if relativeAngle > 180 then
relativeAngle = relativeAngle - 360
elseif relativeAngle < -180 then
relativeAngle = relativeAngle + 360
end
local markerScaleX = 0.5 + (relativeAngle / 360)
-- 3. Логика поведения на границах (Флаг HIDE_ON_BORDER)
if markerScaleX >= MIN_BORDER and markerScaleX <= MAX_BORDER then
child.Visible = true
child.Position = UDim2.new(markerScaleX, 0, 0.5, 0)
else
if HIDE_ON_BORDER then
child.Visible = false
else
child.Visible = true
local clampedX = math.clamp(markerScaleX, MIN_BORDER, MAX_BORDER)
child.Position = UDim2.new(clampedX, 0, 0.5, 0)
end
end
else
child.Visible = false
end
end
end
end
end
end
И по итогу получаем нужный результат:
Вот теперь всё по взрослому.
Ну и собственно, прилагаю сам плейс: