I guess I don’t understand your intended approach here. In your original post, you stated the goal is to create bullets that are “intentionally slow”, which I interpret to mean take time to travel through the air as opposed to being an instant hit scan.
As a result of needing “travel time”, the calculation needs to happen over time as well. I mentioned in my post about calculations happening in discrete “updates”, where each call to the update function has the following responsibilities:
- Based on time since the projectile was spawned, calculate the current goal position
- Perform a raycast between the projectile’s position on the previous update and the new goal position
- If nothing is hit, update the projectile’s visual position to the goal position. If something were hit on the way, it would process the hit (e.g. destruct projectile, damage target, play effects, etc.)
The most practical solution would call this update function every frame, for example on every PreRender
/ RenderStepped
event of RunService.
I messed around with some physics equations today and came up with a fun projectile motion script to tinker with. The thrust/drag/lift are sort of unrealistic estimations, but functionally they can be tweaked to achieve one’s desired result.
This has way more variables than what you need for what you were trying to accomplish, but I’ll share the whole thing for posterity.
Normally I’d split this into several scripts, but it’s written in a way that you can cut out parts you don’t need and see the update cycle & spherecasting working as I understood your request in your original post.
This can be run in a server script in ServerScriptService in an empty baseplate. Tweak the parameters for different projectile types and have some fun with it, if you want.
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local AIR_DENSITY = Workspace.AirDensity
local GRAVITY = Workspace.Gravity
export type OnHitCallback = (projectile: Projectile, raycastResult: RaycastResult) -> boolean
export type OnUpdatedCallback = (projectile: Projectile) -> ()
export type OnDestroyedCallback = (projectile: Projectile) -> ()
export type Projectile = {
projectileType: ProjectileType,
startPosition: Vector3,
currentPosition: Vector3,
lastPosition: Vector3,
startVelocity: Vector3,
currentVelocity: Vector3,
lastVelocity: Vector3,
thrustForce: number,
radius: number,
area: number,
mass: number,
dragCoefficient: number,
liftCoefficient: number,
startTimeMillis: number,
lastTimeMillis: number,
currentTimeMillis: number,
expireTimeMillis: number,
onUpdatedCallback: OnUpdatedCallback?,
onHitCallback: OnHitCallback,
onDestroyedCallback: OnDestroyedCallback?,
}
export type ProjectileType = "Bullet" | "Rocket"
local raycastParams = RaycastParams.new()
local ProjectileType = {
Bullet = "Bullet" :: "Bullet",
Rocket = "Rocket" :: "Rocket",
}
export type stats = {
speed: number,
diameter: number,
mass: number,
dragCoefficient: number,
liftCoefficient: number,
thrustForce: number,
damage: number,
maxLifetimeMillis: number,
}
local statsByProjectileType: { [ProjectileType]: stats } = {
[ProjectileType.Bullet] = {
speed = 200,
diameter = 1,
mass = 1,
dragCoefficient = 1,
liftCoefficient = 0,
thrustForce = 0,
damage = 10,
maxLifetimeMillis = 7000,
},
[ProjectileType.Rocket] = {
speed = 30,
diameter = 2,
mass = 1,
dragCoefficient = 1,
liftCoefficient = 0.35,
thrustForce = 500,
damage = 50,
maxLifetimeMillis = 7000,
},
}
local function createProjectile(
projectileType: ProjectileType,
startPosition: Vector3,
startVelocity: Vector3,
thrustForce: number,
radius: number,
mass: number,
dragCoefficient: number,
liftCoefficient: number,
startTimeMillis: number,
expireTimeMillis: number,
onHitCallback: OnHitCallback,
onUpdatedCallback: OnUpdatedCallback?,
onDestroyedCallback: OnDestroyedCallback?
): Projectile
local projectile: Projectile = {
projectileType = projectileType,
startPosition = startPosition,
currentPosition = startPosition,
lastPosition = startPosition,
startVelocity = startVelocity,
currentVelocity = startVelocity,
lastVelocity = startVelocity,
thrustForce = thrustForce,
radius = radius,
area = math.pi * radius ^ 2,
mass = mass,
dragCoefficient = dragCoefficient,
liftCoefficient = liftCoefficient,
startTimeMillis = startTimeMillis,
lastTimeMillis = startTimeMillis,
currentTimeMillis = startTimeMillis,
expireTimeMillis = expireTimeMillis,
onUpdatedCallback = onUpdatedCallback,
onHitCallback = onHitCallback,
onDestroyedCallback = onDestroyedCallback,
}
return projectile
end
local function updateProjectile(projectile: Projectile)
projectile.lastTimeMillis = projectile.currentTimeMillis
projectile.lastPosition = projectile.currentPosition
projectile.lastVelocity = projectile.currentVelocity
projectile.currentTimeMillis = DateTime.now().UnixTimestampMillis
if projectile.currentTimeMillis > projectile.expireTimeMillis then
if projectile.onDestroyedCallback then
projectile.onDestroyedCallback(projectile)
end
return
end
local deltaTime = (projectile.currentTimeMillis - projectile.lastTimeMillis) / 1000
if deltaTime <= 0 then
return
end
local horizontalSpeed = Vector3.new(projectile.lastVelocity.X, 0, projectile.lastVelocity.Z).Magnitude
local liftForce = Vector3.yAxis
* 0.5
* projectile.liftCoefficient
* projectile.area
* AIR_DENSITY
* horizontalSpeed ^ 2
local dragForce = 0.5
* projectile.dragCoefficient
* projectile.area
* AIR_DENSITY
* projectile.lastVelocity.Magnitude ^ 2
local localDragForce = -dragForce * projectile.lastVelocity.Unit
local worldGravityForce = projectile.mass * -GRAVITY * Vector3.yAxis
local thrustForce = projectile.thrustForce * projectile.lastVelocity.Unit
local netForce = liftForce + thrustForce + localDragForce + worldGravityForce
local netAcceleration = netForce / projectile.mass
projectile.currentPosition = projectile.lastPosition
+ projectile.lastVelocity * deltaTime
+ 0.5 * netAcceleration * deltaTime ^ 2
local currentDirection = projectile.currentPosition - projectile.lastPosition
projectile.currentVelocity = currentDirection / deltaTime
if projectile.onUpdatedCallback then
projectile.onUpdatedCallback(projectile)
end
local raycastResult =
Workspace:Spherecast(projectile.lastPosition, projectile.radius, currentDirection, raycastParams)
if raycastResult then
local isValidHit = projectile.onHitCallback(projectile, raycastResult)
if isValidHit then
if projectile.onDestroyedCallback then
projectile.onDestroyedCallback(projectile)
end
end
end
end
local function updateAllProjectiles(allProjectiles: { [Projectile]: true })
for projectile, _ in allProjectiles do
updateProjectile(projectile)
end
end
local function createPartForProjectileType(projectileType: ProjectileType)
local stats = statsByProjectileType[projectileType]
local projectilePart = Instance.new("Part")
projectilePart.Size = Vector3.new(stats.diameter, stats.diameter, stats.diameter)
projectilePart.Shape = Enum.PartType.Ball
projectilePart.Color = if projectileType == ProjectileType.Bullet then Color3.new(0, 0, 1) else Color3.new(1, 0, 0)
projectilePart.Anchored = true
projectilePart.CanCollide = false
projectilePart.CanQuery = false
projectilePart.Transparency = 0
projectilePart.Name = projectileType
return projectilePart
end
local allProjectiles: { [Projectile]: true } = {}
local projectilePartByProjectile: { [Projectile]: BasePart } = {}
RunService.PreRender:Connect(function()
updateAllProjectiles(allProjectiles)
end)
local onHitCallback: OnHitCallback = function(projectile: Projectile, raycastResult: RaycastResult)
local isValidHit = (raycastResult.Instance :: BasePart).Transparency < 1
if isValidHit then
local stats = statsByProjectileType[projectile.projectileType]
print(`{projectile.projectileType} hit {raycastResult.Instance.Name} dealing {stats.damage} damage`)
end
return isValidHit
end
local onUpdatedCallback: OnUpdatedCallback = function(projectile: Projectile)
local projectilePart = projectilePartByProjectile[projectile]
local direction = projectile.currentPosition - projectile.lastPosition
projectilePart.CFrame =
CFrame.lookAt(projectile.currentPosition, projectile.currentPosition + direction, Vector3.yAxis)
end
local onDestroyedCallback: OnDestroyedCallback = function(projectile: Projectile)
local projectilePart = projectilePartByProjectile[projectile]
projectilePart:Destroy()
projectilePartByProjectile[projectile] = nil
allProjectiles[projectile] = nil
end
local function createTestProjectileAtPart(originPart: BasePart, projectileType: ProjectileType)
local stats = statsByProjectileType[projectileType]
local startPosition = originPart.Position
local startVelocity = originPart.AssemblyLinearVelocity + originPart.CFrame.LookVector * stats.speed
local startTimeMillis = DateTime.now().UnixTimestampMillis
local expireTimeMillis = startTimeMillis + stats.maxLifetimeMillis
local radius = stats.diameter / 2
local projectile = createProjectile(
projectileType,
startPosition,
startVelocity,
stats.thrustForce,
radius,
stats.mass,
stats.dragCoefficient,
stats.liftCoefficient,
startTimeMillis,
expireTimeMillis,
onHitCallback,
onUpdatedCallback,
onDestroyedCallback
)
allProjectiles[projectile] = true
local projectilePart = createPartForProjectileType(projectileType)
projectilePart.CFrame = CFrame.lookAt(startPosition, startPosition + startVelocity, Vector3.yAxis)
projectilePartByProjectile[projectile] = projectilePart
projectilePart.Parent = Workspace
end
local function runTestAsync()
local originPart = Instance.new("Part")
originPart.CFrame = CFrame.new(0, 10, 0)
originPart.Anchored = true
originPart.Size = Vector3.one
originPart.Transparency = 0.5
originPart.Color = Color3.new(0, 1, 0)
originPart.AssemblyLinearVelocity = Vector3.zero
originPart.CanCollide = false
originPart.CanQuery = false
originPart.Parent = Workspace
originPart.CFrame = originPart.CFrame * CFrame.Angles(math.pi / 4, 0, 0)
while true do
createTestProjectileAtPart(originPart, ProjectileType.Bullet)
task.wait(0.25)
createTestProjectileAtPart(originPart, ProjectileType.Rocket)
task.wait(0.25)
end
end
task.spawn(runTestAsync)
As a result of trying to incorporate “thrust” for rockets (and subsequently lift and drag), I had to do calculations on a per-frame basis based on values calculated on the previous frame. If you don’t need this and all you have is world-oriented gravity, you could simplify all the math and calculate position deterministically using only the starting values as I explained in an earlier post.