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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
local ufoModule = require(game.ServerStorage.UfoModule)
while task.wait() do
ufoModule:update()
end