Isometric Screen Bounds

I made this Pacman screen-bounds system on the x-axis but I was wondering how I would get the same effect for the z-axis if anyone would know the math for such.

local playerService = game:GetService("Players")
local runService = game:GetService('RunService')

local player = playerService.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")
local hrp = character:WaitForChild("HumanoidRootPart")

local function IsObjectVisible(Object: BasePart)
	local ObjectPosition = Object.Position

	local Players = game:GetService("Players")
	local Player = Players.LocalPlayer

	local Camera = workspace.CurrentCamera
	local Character = Player.Character

	local CameraPosition = Camera.CFrame.Position

	local Position = (CameraPosition + ObjectPosition) * 0.5
	local Diff = (CameraPosition - ObjectPosition)

	local _, OnScreen = Camera:WorldToScreenPoint(ObjectPosition)
	local Params = OverlapParams.new()
	
	Params.FilterDescendantsInstances = {Character, game.Workspace:GetDescendants(), Object}
	
	local BoundBox = workspace:GetPartBoundsInBox(CFrame.new(Position, Position + Diff.Unit), Vector3.zAxis * Diff.Magnitude, Params)

	return OnScreen and #BoundBox == 0
end

function cardinalConvert(dir)
	local angle = math.atan2(dir.X, -dir.Z)
	local quarterTurn = math.pi / 2
	angle = -math.round(angle / quarterTurn) * quarterTurn

	local newX = -math.sin(angle)
	local newZ = -math.cos(angle)
	if math.abs(newX) <= 1e-10 then newX = 0 end
	if math.abs(newZ) <= 1e-10 then newZ = 0 end
	return Vector3.new(newX, 0, newZ)
end

runService.RenderStepped:Connect(function()	
	local teleportOffset = 3
	if IsObjectVisible(hrp)then
		--print("Is Onscreen")
	else
		--print("Is Offscreen")
		local hrpX, hrpY, hrpZ = hrp.Position.X , hrp.Position.Y , hrp.Position.Z 
		--print(cardinalConvert(humanoid.MoveDirection))
		if cardinalConvert(humanoid.MoveDirection) == Vector3.new(1,0,0) then
			character:PivotTo(CFrame.new(Vector3.new(hrpZ + teleportOffset, hrpY, hrpX - teleportOffset)))
			--print("Right")
		elseif cardinalConvert(humanoid.MoveDirection) == Vector3.new(-1,0,0) then
			character:PivotTo(CFrame.new(Vector3.new(hrpZ - teleportOffset, hrpY, hrpX + teleportOffset)))
			--print("Left")
		elseif cardinalConvert(humanoid.MoveDirection) == Vector3.new(0,0,1) then
			--print("Down")
		elseif cardinalConvert(humanoid.MoveDirection) == Vector3.new(0,0,-1) then
			--print("Up")
		end	
	end
end)

2 Likes

The following is my attempt. I’ve tried to comment it as best I can but lmk if you have questions.

Video

Example Code
--[!] SERVICES
local RunService = game:GetService('RunService')
local PlayerService = game:GetService('Players')


--[!] CONST
local EPSILON = 1e-6
local XZ_VECTOR = Vector3.new(1, 0, 1)

local CAMERA_FOV = 10               -- desired camera field of view
local CAMERA_HEIGHT = 100           -- desired camera height
local CAMERA_ORIGIN = Vector3.zero  -- origin point of the camera

local THRESHOLD_OFFSET = 6          -- how far we push the player over the other side when tping
local THRESHOLD_INTERSECT = 1       -- padding to intersect threshold


--[!] UTILS

--> essentially the same as calling `.Unit` on a vector
--   used to ensure we safely normalise & don't renormalise a vec
local function getNormalisedVector(vec)
  if vec == Vector3.zero then
    return vec
  end

  local d = vec.X*vec.X + vec.Y*vec.Y + vec.Z*vec.Z
  if d > 1 then
    return vec.Unit
  end

  return vec
end

--> computes a plane from 3 points
--    learn more about planes here, e.g. ref:
--     - https://gdbooks.gitbooks.io/3dcollisions/content/Chapter1/plane.html
--     - https://en.wikipedia.org/wiki/Plane_(mathematics)
--
local function planeFromPoints(a, b, c, output)
  local normal = (b - a):Cross(c - a).Unit
  local distance = -normal:Dot(a)

  if not output then
    output = table.create(2)
  end

  output[1] = normal
  output[2] = distance
  return output
end

--> used to compute the frustum of the camera
--   Learn more about viewing frustum e.g. ref @ https://en.wikipedia.org/wiki/Viewing_frustum
--
local function buildFrustumFromCamera(camera, farPlaneZ, output)
  farPlaneZ = farPlaneZ or 200

  local transform = camera.CFrame
  local translation = transform.Position
  local nearPlaneZ = camera.NearPlaneZ

  local fieldOfView = camera.FieldOfView
  local tanHalfFov = math.tan(math.rad(fieldOfView * 0.5))

  local viewportSize = camera.ViewportSize
  local viewportAspectRatio = viewportSize.X / viewportSize.Y

  local farPlaneHeight_2 = tanHalfFov*farPlaneZ
  local farPlaneWidth_2 = farPlaneHeight_2*viewportAspectRatio

  local nearPlaneHeight_2 = tanHalfFov*-nearPlaneZ
  local nearPlaneWidth_2 = nearPlaneHeight_2*viewportAspectRatio

  farPlaneZ *= -1

  local farTopLeft      = transform * Vector3.new( -farPlaneWidth_2,   farPlaneHeight_2, farPlaneZ)
  local farTopRight     = transform * Vector3.new(  farPlaneWidth_2,   farPlaneHeight_2, farPlaneZ)
  local farBottomRight  = transform * Vector3.new(  farPlaneWidth_2,  -farPlaneHeight_2, farPlaneZ)

  local nearTopLeft     = transform * Vector3.new(-nearPlaneWidth_2,  nearPlaneHeight_2, nearPlaneZ)
  local nearTopRight    = transform * Vector3.new( nearPlaneWidth_2,  nearPlaneHeight_2, nearPlaneZ)
  local nearBottomLeft  = transform * Vector3.new(-nearPlaneWidth_2, -nearPlaneHeight_2, nearPlaneZ)
  local nearBottomRight = transform * Vector3.new( nearPlaneWidth_2, -nearPlaneHeight_2, nearPlaneZ)

  output = output or { }
  output.transform = transform
  output.translation = translation
  output.viewportSize = viewportSize

  local frustumPlanes = output.planes
  if not frustumPlanes then
    frustumPlanes = table.create(6)
    output.planes = frustumPlanes
  end
  frustumPlanes[1] = planeFromPoints(   nearTopRight, nearBottomRight,     nearTopLeft, frustumPlanes[1]) -- near
  frustumPlanes[2] = planeFromPoints(    farTopRight,      farTopLeft,  farBottomRight, frustumPlanes[2]) -- far
  frustumPlanes[3] = planeFromPoints(   nearTopRight,     nearTopLeft,     farTopRight, frustumPlanes[3]) -- top
  frustumPlanes[4] = planeFromPoints(nearBottomRight,  farBottomRight,  nearBottomLeft, frustumPlanes[4]) -- bottom
  frustumPlanes[5] = planeFromPoints(    nearTopLeft,  nearBottomLeft,      farTopLeft, frustumPlanes[5]) -- left
  frustumPlanes[6] = planeFromPoints(   nearTopRight,     farTopRight, nearBottomRight, frustumPlanes[6]) -- right

  return output
end

--> used to check whether a player is alive
--    i.e. one that's alive and has both a humanoid + a root part
local function isAlive(character)
  if typeof(character) ~= 'Instance' or not character:IsA('Model') or not character:IsDescendantOf(workspace) then
    return false
  end

  local humanoid = character:FindFirstChildOfClass('Humanoid')
  local humanoidState = humanoid and humanoid:GetState() or Enum.HumanoidStateType.Dead
  local humanoidRootPart = humanoid and humanoid.RootPart or nil
  if humanoidState == Enum.HumanoidStateType.Dead or not humanoidRootPart then
    return false
  end

  return true
end

--> used to await a player's character
--   i.e. one that exists + is alive
local function tryGetCharacter(player)
  if typeof(player) ~= 'Instance' or not player:IsA('Player') then
    return nil
  end

  local character
  while not character do
    if not player or not player:IsDescendantOf(PlayerService) then
      break
    end

    local char = player.Character
    if not char then
      player.CharacterAdded:Wait()
      continue
    end

    if not isAlive(char) then
      RunService.Stepped:Wait()
      continue
    end

    character = char
  end

  return character
end

--> used to cleanup any connections/instances
local function cleanupDisposables(disposables)
  for i = 1, #disposables, 1 do
    local disposable = disposables[i]
    local t = typeof(disposable)
    if t == 'RBXScriptConnection' then
      pcall(disposable.Disconnect, disposable)
    elseif t == 'Instance' then
      pcall(disposable.Destroy, disposable)
    end
  end
  table.clear(disposables)
end


--[!] MAIN
local function beginTracking(player)
  local character = tryGetCharacter(player)
  if not character then
    return
  end

  -- cleanup
  local disposables = { }

  -- set up camera
  local camera = game.Workspace.CurrentCamera
  local offset = (10 / math.clamp(CAMERA_FOV, 1, 90)) * CAMERA_HEIGHT
  local cameraOrigin = CFrame.lookAt(CAMERA_ORIGIN + Vector3.one*offset, CAMERA_ORIGIN)
  camera.CFrame = cameraOrigin
  camera.CameraType = Enum.CameraType.Scriptable
  camera.FieldOfView = CAMERA_FOV
  camera.CameraSubject = nil

  -- set up camera frustum
  --    we're going to use the planes of the camera as "walls"
  --    so that we can check to see if a player is inside/outside
  --    those specific walls
  local frustum = buildFrustumFromCamera(camera, offset)
  local frustumPlanes = frustum.planes

  -- set up character listener(s)
  local humanoid = character:FindFirstChildOfClass('Humanoid')
  local rootPart = humanoid.RootPart

  local runtime
  runtime = RunService.Stepped:Connect(function (gt, dt)
    -- cleanup if we're dead
    if not isAlive(character) then
      return cleanupDisposables(disposables)
    end

    local transform = rootPart.CFrame
    local translation = transform.Position

    -- normalise the movementVector because this isn't
    -- always normalised, e.g. when on a mobile device
    local moveDirection = getNormalisedVector(humanoid.MoveDirection*XZ_VECTOR)
    local moveMagnitude = moveDirection.Magnitude

    local moveDistance
    if moveMagnitude > EPSILON then
      local speed = humanoid.WalkSpeed*dt
      moveDistance = moveMagnitude*speed
    end

    local state = humanoid:GetState()
    local isJumping = state == Enum.HumanoidStateType.Jumping or state == Enum.HumanoidStateType.Freefall

    -- we only care if they intersect the top/left/bottom/right
    -- planes of the frustum, so we're offseting to avoid checking
    -- the near and far planes

    local plane, isInside, planeNormal, planeDistance, pointDistance
    for i = 3, 6, 1 do
      -- ignore top/bottom surface if we're jumping
      -- so we don't teleport when jumping into the
      -- top and bottom surfaces
      if isJumping and (i == 3 or i == 4) then
        continue
      end

      plane = frustumPlanes[i]
      planeNormal, planeDistance = plane[1], plane[2]

      -- check to see which side of the plane we're on
      pointDistance = planeNormal:Dot(translation) + planeDistance
      isInside = pointDistance > 0

      -- i.e. check to see whether we're:
      --       a) already outside
      --   OR; b) are about to step outside
      --
      local point
      if not isInside or (moveDistance and pointDistance <= moveDistance + THRESHOLD_INTERSECT) then
        -- get the closest point on the current plane
        point = translation - planeNormal*pointDistance
      end

      -- ignore if neither condition is met
      if not point then
        continue
      end

      -- teleport the player to the other side by first getting the
      -- opposite plane, then positioning them relative to the new
      -- plane +/- the desired offset
      local indexOffset = (i - 1) % 2
      indexOffset = indexOffset == 0 and 1 or -1

      plane = frustumPlanes[i + indexOffset]
      planeNormal = plane[1]
      planeDistance = plane[2]

      -- get the closest point on the opposite plane
      local d = planeNormal:Dot(point) + planeDistance
      point = point - planeNormal*d

      -- there's probably an easier way to do this but this is
      -- all i can think of right now ...
      --
      -- i'm just using this to find the lookvector of the plane
      -- from the perspective of the camera, so that we can
      -- project the point back to the character's original
      -- height
      local r = camera:WorldToViewportPoint(point)
      r = camera:ViewportPointToRay(r.X, r.Y, 0)

      -- what we're essentially doing here is treating the
      -- character's upVector and its height from the origin
      -- as a plane, and then getting the intersection point
      -- from the ray and the pseudo-character plane
      local upVector = transform.UpVector
      d = r.Direction:Dot(upVector)
      d = -(frustum.translation:Dot(upVector) - translation.Y) / d

      -- compute the final translation at the character's height
      -- and offset the X/Z coords by the threshold we defined earlier
      point = frustum.translation + r.Direction*d
      point = point + planeNormal*XZ_VECTOR*THRESHOLD_OFFSET

      -- finally, move our character
      character:MoveTo(point)

      break
    end
  end)
  table.insert(disposables, runtime)
end


--[!] INIT
local player = PlayerService.LocalPlayer
if typeof(player.Character) ~= 'nil' then
  beginTracking(player)
end

player.CharacterAdded:Connect(function ()
  return beginTracking(player)
end)

The idea being that we can treat the top/right/bottom/left planes of the frustum as ‘walls’, and when a player crosses that wall, we can get the opposite plane and teleport them there.

e.g. where the orange part in this picture is the camera, and the lines coming from the block are the surfaces of the frustum:

image

1 Like