6.6.2 UFO Script

The alien spaceship will patrol an area near its designed home position.

This is accomplished by generating a random position and moving the ship towards it. Upon arrival, another position will be generated and the cycle repeats.

We’ll do a similar thing for the spotlight, but this time, the “home” position is the ship’s position and the spotlight is moves toward the randomly generated scan location.

To represent the spotlight, a decal is placed on an invisible part and part is what will be moved and resized to match the desired ellipse.

If the part is made very thin, it will look like the spotlight is on the ground.

Finally, there needs to be a function that scans for players. This is done by looping through each player character and calculating the angle they are with respect to the spotlight look vector.

If a player is within the viewcone, call a bombardment on that position.

First off, a bit of housekeeping.

Lua
local CannonModule = require(game.ServerStorage.CannonModule)

local SPOTLIGHT_ID = 'http://www.roblox.com/asset/?id=17695999717'
local UFO_MESH_ID = 'http://www.roblox.com/asset?id=161270281'
local TEXTURE_ID = 'http://www.roblox.com/asset?id=161270249'

local DOWN_VECTOR = -Vector3.yAxis
local FOV = math.rad(15)

local SHIP_SPEED = 15
local SHIP_ALTITUDE = 100
local SPOTLIGHT_HEIGHT = 0.1
local HOME_POSITION = Vector3.new(-76.013, SHIP_ALTITUDE, -242.909)

local MOVE_RANGE = {100, 200}
local SCAN_RANGE = {100, 200}
local SCAN_SPEED = 50
local SEEK_COOLDOWN = 5

Since the UFO will directly invoke the cannon, we’ll load the cannon module into this script.

The angle alpha, from our equation earlier, is the angle which the spotlight look vector makes with the vertical vector. Since the spotlight is pointed down, the vertical vector is negated.

The field of view is also defined here. 30° so the half angle is 15°.

The ship’s home position can be anywhere you want. I have mine set to (-76.013, y, -242.909), which is the XZ position of the cannon.

MOVE_RANGE sets a maximum and minimum range around the home position where the patrol positions can be generated. If all possible positions were mapped out, this span would look like a donut shape with the home position at the center.

Similarly, SCAN_RANGE sets the span for the spotlight. The SPOTLIGHT_HEIGHT sets the height of the part that has the spotlight decal. This is set near the ground to make it look like a light shining against the ground.


Lua
local ufoData = {
	ship = Instance.new("Part"),
	spotlight = Instance.new("Part"),
	spotlightPos = Vector3.new(),
	moveTarget = Vector3.new(),
	lightTarget = Vector3.new(),
	lastDetect = 0,
	seeking = false
}

A table holds all the data about the ship and spotlight. This will be passed into the functions that move the ship and update the spotlight.

Because position of the spotlight part and the spotlight look position are unlikely to align due to the elliptical offset, we need a separate variable to keep track of the look position.

After a player is detected, the ship needs to disable scanning for a short time otherwise it will call an attack on the character every frame until it loses vision. The ‘seeking’ flag disables scan and sets the spotlight visibility.


Lua
local function randomSpanV3(range)
	local distance = math.random(range[1], range[2])
	local rad = math.random() * math.pi * 2
	local x = math.cos(rad) * distance
	local z = math.sin(rad) * distance

	return Vector3.new(x, 0, z)
end

local function getSpotSizeCF(origin, targetPosition)
	local direction = targetPosition - origin
	local distance = direction.Magnitude
	local look = direction.Unit
	local alpha = DOWN_VECTOR:Angle(look)
	local theta = math.pi / 2 - alpha
	local forwardVector = Vector3.new(look.X, 0, look.Z).Unit

	local beta = math.pi / 2 + FOV
	local gamma = math.pi / 2 - FOV	

	local C = distance * math.tan(FOV)
	local major1 = C * (math.sin(beta) / math.sin(gamma - alpha))
	local major2 = C * (math.sin(gamma) / math.sin(beta - alpha))

	local e = math.cos(math.pi / 2 - alpha) / math.cos(FOV)
	local e2 = e * e
	local inner = 1 - e2
	local semimajor = (major1 + major2) / 2
	local semimajor2 = semimajor * semimajor
	local b = math.sqrt(inner * semimajor2)
	local minorAxis = 2 * b

	local incident = 0.5 * major1 * (1 - (major2 / major1))
	local spotPos = targetPosition + forwardVector * incident

	return Vector3.new(minorAxis, SPOTLIGHT_HEIGHT, major1 + major2), CFrame.new(spotPos, spotPos + forwardVector) 
end

These are helper functions.

The ‘getSpotSizeCF’ function implements the ellipse function from earlier to calculate the dimensions for the part and set its CFrame.

After the ellipse’s size has been calculated, the part’s position needs to be offset relative to the look position to compensate for the eccentricity.

The ‘forwardVector’ is a purely horizontal direction vector that points from the ship to the spotlight and represents the direction the part needs to be offset.


Lua
local function scanForPlayers(pos, targetPosition)
	local look = (targetPosition - pos).Unit
	local spotted = {}

	for _,player in ipairs(game.Players:GetPlayers()) do
		local character = player.Character

		if character then
			local charPos = character.PrimaryPart.Position
			local charDirection = (charPos - pos).Unit

			if look:Angle(charDirection) < FOV then
				spotted[character.Name] = true
				table.insert(spotted, character)
			end
		end
	end

	return spotted
end

This function loops through every player and calculates the look vector to their character.

The vector is then compared with the spotlight’s look vector to determine if the character is within the viewcone. If the check passes, the character is added to a list of targets.

This uses the ‘angle’ method to which automatically converts the dot product to angles. If you are using the dot product, comparing the cosine of the FoV achieves the same thing.

Lua
local dot = look:Dot(charDirection)
math.acos(dot) < FOV

Let’s take a moment to talk about ‘delta time.’ We’ve touched on this concept a bit in previous chapters but didn’t need to delve deeper in detail.

As the name implies, delta time means ‘difference in time’ or ‘time change.’ But why is that important?

In game development, you will often encounter tasks that need to be performed every frame or need to be spread over some amount of time, such as an animation or part move.

This means that often theses tasks are also time dependent.

For instance, Roblox creates a new frame 60 times per second by default or about 16.67 milliseconds between each frame.

So let’s say you wanted to move a part 10 studs in 1 second. This means that at 60 FPS, the part moves 0.167 studs each frame (10/60 = 0.166667).

The simplest way to move the part is just to add/subtract that distance to the part’s current position each frame.

However, the problem is that there is no guarantee the OS will render every frame at exactly 16 millisecond intervals.

Add the widely varying performance of devices and it is not feasible to use the framerate as a teams to time tasks.

Lua
while true do
	local dt = task.wait()
	print(dt)
end

-- 0.016691500000000303
-- 0.015928299999998785
-- 0.018236899999998002
-- 0.015376299999999787
-- 0.01553390000000121
-- 0.016855700000000695
-- 0.017526799999998843
-- 0.01582970000000472
-- 0.015694299999999828
-- 0.016889900000002456

Even with this empty loop, each time around varies by a not insignificant amount.

If a player has a machine that can only maintain 30 FPS, using the technique above would only move the part 5 studs because their system would only run the loop 30 times in that second.

The solution then, is to not couple the operation to the frame but to the time elapsed since the function was called.

Since time won’t vary like frames per second, integrating the time difference into a calculation will compensate for this system inconsistency.


Lua
local function updateShip(dt, shipData)
	local position = shipData["ship"].Position
	local direction = shipData["moveTarget"] - position
	local distance = direction.Magnitude
	local look = direction.Unit
	local velocity = look * SHIP_SPEED * dt
	local newPosition = position + velocity
	shipData["ship"].Position = newPosition
	
	if distance < 1 then
		shipData["moveTarget"] = HOME_POSITION + Vector3.new(randSpan(MOVE_RANGE), 0, randSpan(MOVE_RANGE))
		
	end
end

Applying that idea to the ship update function, delta time along with the ship speed is used to determine how far to move the ship each frame.

This way, if the previous frame took a long time to process, the move distance scales proportionately with delta time.

In this function, we also encounter the ‘look’ direction vector again. Because the magnitude is 1, multiplying it with the desired distance scales each dimension properly.

Then finally, when the ship gets within 1 stud of the target position, run another statement that will generate a new position.


Lua
local function updateSpotlight(dt, shipData)
	local ufo = shipData["ship"]
	local spotlight = shipData["spotlight"]
	local targetPosition = shipData["lightTarget"]
	local lightPosition = shipData["spotlightPos"]
	
	local shipPos = ufo.Position
	
	local direction = targetPosition - lightPosition
	local distance = direction.Magnitude
	local look = direction.Unit
	local velocity = look * SCAN_SPEED * dt
	local newPosition = lightPosition + velocity
	shipData["spotlightPos"] = newPosition
	
	if shipData["seeking"] then
		local size, spotCf = getSpotSizeCF(shipPos, newPosition)
		spotlight.CFrame = spotCf
		spotlight.Size = size
		
	else
		spotlight.Position = Vector3.new(0, 100, 0)
		spotlight.Size = Vector3.one
	end	
	
	if distance < 1 then
		local groundPos = Vector3.new(shipPos.X, SPOTLIGHT_HEIGHT, shipPos.Z)
		local nextPosition = Vector3.new(randSpan(SCAN_RANGE), 0, randSpan(SCAN_RANGE))
		shipData["lightTarget"] = groundPos + nextPosition
	end
end

Moving the spotlight position is similar to moving the ship. If the ‘seeking’ flag is not set, hide the spotlight part somewhere that is not visible to players.

Otherwise, calculate the spotlight size and position and update it.


Lua
local lastTick = tick()

local function update()
	local t2 = tick()
	local dt = t2 - lastTick

	updateShip(dt, ufoData)
	updateSpotlight(dt, ufoData)

	if ufoData["seeking"] then
		local foundPlayers = scanForPlayers(ufoData["ship"].Position, ufoData["spotlightPos"])
		if #foundPlayers > 0 then
			CannonModule:attackPlayer(foundPlayers[math.random(#foundPlayers)])
			ufoData["lastDetect"] = t2

		end
	end

	ufoData["seeking"] = t2 - ufoData["lastDetect"] > SEEK_COOLDOWN
	lastTick = t2
end

This is the master update function which invokes the other update functions. First the ship is moved. Then the spotlight. And then scan for players.

You may choose to structure yours differently, integrate the scan elsewhere, or from an external script even.

When scanning for players, it will be exceptionally rare that more than one character is returned but in the off chance that it does, a random character from the list is selected.


Finally, just a last bit of housekeeping that initializes the ship and spotlight.

Lua

do
	local specialMesh = Instance.new("SpecialMesh")
	specialMesh.MeshId = UFO_MESH_ID
	specialMesh.TextureId = TEXTURE_ID
	specialMesh.Parent = ufoData["ship"]

	local decal = Instance.new("Decal")
	decal.Texture = SPOTLIGHT_ID
	decal.Face = Enum.NormalId.Top
	decal.Color3 = Color3.new(0, 1, 0)
	decal.Transparency = 0.75
	decal.Parent = ufoData["spotlight"]
	
	ufoData["spotlight"].Anchored = true
	ufoData["spotlight"].CanCollide = false
	ufoData["spotlight"].CanTouch = false
	ufoData["spotlight"].Position = Vector3.new(0, 100, 0)
	ufoData["spotlight"].Size = Vector3.new(4, SPOTLIGHT_HEIGHT, 4)
	ufoData["spotlight"].Transparency = 1.0
	ufoData["spotlight"].Parent = game.Workspace
	
	ufoData["ship"].Anchored = true
	ufoData["ship"].Name = "UFO"
	ufoData["ship"].Position = Vector3.new(0, SHIP_ALTITUDE, 0)
	ufoData["ship"].Parent = game.Workspace
	
	ufoData["spotlightPos"] = Vector3.new(ufoData["ship"].Position.X, SPOTLIGHT_HEIGHT, ufoData["ship"].Position.Z)
	ufoData["moveTarget"] = HOME_POSITION + Vector3.new(randSpan(MOVE_RANGE), 0, randSpan(MOVE_RANGE))
	ufoData["lightTarget"] = ufoData["spotlightPos"] + Vector3.new(randSpan(SCAN_RANGE), SPOTLIGHT_HEIGHT, randSpan(SCAN_RANGE))
end

Currently, MeshParts cannot be updated during runtime so we’re using a SpecialMesh of a MeshPart. When parented to a part, the SpecialMesh causes the part’s visuals to take the shape of the mesh.

This does not affect the touch or collision of the part but we do not need them so the SpecialMesh is adequate.

Since the spotlight part is there just to contain the decal, the part is set to invisible. The decal is a blank white circle, so whichever Color3 you give it, the spotlight will take assume the color.

Lua
local UfoModule = {}

function UfoModule:update()
	update()
end

return UfoModule

Moving on to the “module” part of the scripts, this consists of a single update function that will be invoked from a caller script every heartbeat.


And finally, the main script. This will call the module’s update function every heartbeat.

Lua
local ufoModule = require(game.ServerStorage.UfoModule)

while task.wait() do
	ufoModule:update()
end