Last active
April 17, 2025 22:50
-
-
Save magicoal-nerb/49c3831fd63004debf6ab774f42331be to your computer and use it in GitHub Desktop.
really simple quake movement in roblx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- poopbarrel/magical_noob | |
-- @submodule quakec | |
-- @description another quake movement reimplementation ig | |
-- Physics | |
local XZ = Vector3.new(1.0, 0.0, 1.0); | |
local CAPSULE_FLOOR_ANGLE = 0.7; | |
-- Convars | |
local PL_HITBOX_SIZE = Vector3.new(4, 6, 4); | |
local PL_GROUND_ACCELERATION = 500; | |
local PL_GROUND_FRICTION = 10; | |
local PL_AIR_ACCELERATION = 500; | |
local PL_GRAVITY = 100; | |
local PL_MAX_AIR = 2; | |
-- Keymap | |
local QUAKEC_KEYMAP = { | |
Enum.KeyCode.W, | |
Enum.KeyCode.A, | |
Enum.KeyCode.S, | |
Enum.KeyCode.D | |
}; | |
local QUAKEC_KEYMAP_VEC = { | |
Vector3.new(0, 0, -1); | |
Vector3.new(-1, 0, 0); | |
Vector3.new(0, 0, 1); | |
Vector3.new(1, 0, 0); | |
}; | |
-- Services | |
local ContextActionService = game:GetService("ContextActionService"); | |
local RunService = game:GetService("RunService"); | |
local Players = game:GetService("Players"); | |
local CurrentCamera = workspace.CurrentCamera; | |
local quakec = {}; | |
quakec.__index = quakec; | |
local function quakeAccelerate( | |
input, | |
velocity, | |
accelerate, | |
maxVelocity, | |
dt | |
) | |
-- Constrain min(dot(v, i + i*dt), maxVel) | |
local projection = velocity:Dot(input); | |
local accel = math.max(math.min(accelerate * dt, maxVelocity - projection), 0.0); | |
return velocity + accel * input; | |
end | |
local function utilClosestPointOnLines(a, dirA, b, dirB) | |
local ba = a - b; | |
local axb = dirA:Cross(dirB); | |
-- For our line, our space is within 0<=t<=1, so clamp the result. | |
if(axb.Magnitude < 1e-3)then | |
-- Degenerate case; we have to get the closest point assuming b is a point. | |
-- dot(a + dirA*lambda - b, dirA) = 0 | |
-- dot(a - b, dirA) + dot(dirA, dirA)lambda = 0 | |
-- lambda = dot(b - a, dirA)/dot(dirA, dirA) | |
local t = math.clamp((b-a):Dot(dirA) / dirA:Dot(dirA), 0.0, 1.0); | |
return a + dirA * t, b; | |
else | |
-- Consider two lines: a + dirA*lambda_1 = b + dirB*lambda_2 | |
-- ba = dirB*lambda_2 - dirA*lambda_1 ; let ba = a - b | |
-- A trick we can do to eliminate one of our unknowns is to cross both sides | |
-- as a line perpendicular to both a and b is the closest line. | |
-- cross(ba, dirB) = -cross(dirA,dirB)*lambda_1 | |
-- dot(cross(ba, dirA), cross(dirA, dirB)) = -|cross(dirA, dirB)|^2 * lambda_1 | |
-- -dot(cross(ba, dirA), cross(dirA, dirB)) / |cross(dirA, dirB)|^2 = lambda_1 | |
-- cross(ba, dirA) = cross(dirB,dirA)*lambda_2 | |
-- dot(cross(ba, dirA), cross(dirB, dirA)) = |cross(dirB, dirA)|^2 * lambda_2 | |
-- dot(cross(ba, dirA), cross(dirB, dirA)) / |cross(dirB, dirA)|^2 = lambda_2 | |
-- => -dot(cross(ba, dirA), cross(dirA, dirB)) / |cross(dirA, dirB)|^2 = lambda_2 | |
local invAxb = 1.0 / axb:Dot(axb); | |
local t1 = math.clamp(-ba:Cross(dirB):Dot(axb) * invAxb, 0.0, 1.0); | |
local t2 = math.clamp(-ba:Cross(dirA):Dot(axb) * invAxb, 0.0, 1.0); | |
return a + dirA * t1, | |
b + dirB * t2; | |
end | |
end | |
local function utilQuaternionXZ(quat) | |
-- Project the quaternion onto the XZ plane | |
-- making it only the y rotation | |
local up = quat.UpVector; | |
return CFrame.new( | |
0, 0, 0, | |
-up.Z, 0, up.X, up.Y+1 | |
) * quat; | |
end | |
local function utilSphereRayPlane( | |
a, | |
rayDir, | |
radius, | |
origin, | |
normal | |
) | |
-- dot(a + rayDir*s - origin, normal)^2 = radius^2 | |
-- dot(a - origin, normal) + dot(rayDir, normal)s = radius | |
-- s = (radius - dot(a - origin, normal))/dot(rayDir, normal) | |
local ao = a - origin; | |
local s = (radius - ao:Dot(normal)) / rayDir:Dot(normal); | |
local sClamped = math.clamp(s, 0.0, 1.0); | |
return a + rayDir * sClamped, sClamped; | |
end | |
local function utilConstrain(disp, normal) | |
-- Minimize dot(disp, normal), given velocity + normal*lambda_1 | |
local dot = math.min(disp:Dot(normal), 0.0); | |
return disp - normal * dot; | |
end | |
function quakec.new(character) | |
local overlap = OverlapParams.new(); | |
overlap.FilterDescendantsInstances = { character }; | |
overlap.FilterType = Enum.RaycastFilterType.Exclude; | |
local rootPart = character:WaitForChild("HumanoidRootPart"); | |
assert(rootPart, "Could not find a rootpart! You have to specify it yourself."); | |
rootPart.Transparency = 1.0; | |
rootPart.Anchored = true; | |
return setmetatable({ | |
position = rootPart.Position; | |
velocity = rootPart.Velocity; | |
-- parameters | |
height = PL_HITBOX_SIZE * Vector3.yAxis * 0.5; | |
size = PL_HITBOX_SIZE; | |
radius = 1; | |
input = 0; | |
rootPart = rootPart; | |
character = character; | |
overlap = overlap; | |
}, quakec); | |
end | |
function quakec:move(delta) | |
-- Handles collision logic | |
local position = self.position; | |
local velocity = self.velocity; | |
local radius = self.radius; | |
local height = self.height - Vector3.yAxis * radius; | |
local contacts = {}; | |
local parts = workspace:GetPartBoundsInBox( | |
CFrame.new(position + delta*0.5), | |
self.size + delta:Abs() + Vector3.one, | |
self.overlap | |
); | |
local floor; | |
for _, part in parts do | |
if(not part:CanCollideWith(self.rootPart))then | |
-- Don't bother trying colliding with this. | |
continue; | |
end | |
-- Do a quick collision check | |
local point = part:GetClosestPointOnSurface(position); | |
local normal = (position - point).Unit; | |
if(normal ~= normal)then | |
-- NaN | |
continue; | |
end | |
local newPosition, t = utilSphereRayPlane( | |
position, | |
delta, | |
radius, | |
point, | |
normal | |
); | |
if(t < 1.0)then | |
-- We have a speculative contact! | |
position = newPosition; | |
velocity = utilConstrain(velocity, normal); | |
delta = utilConstrain(delta, normal); | |
end | |
table.insert(contacts, { | |
-- We'll extrapolate this for the plane | |
-- pushing step. | |
part = part; | |
p = point; | |
n = normal; | |
}); | |
end | |
local headHeight = height * 0.5; | |
position += delta; | |
for _, contact in contacts do | |
-- We can get a rough approximation of our collision through | |
-- 2 points. It's not pretty, but it works, especially since we don't have | |
-- algorithms like GJK here. | |
local contactPoint0 = contact.part:GetClosestPointOnSurface(position + headHeight); | |
local contactPoint1 = contact.part:GetClosestPointOnSurface(position - height); | |
local p, contactPoint = utilClosestPointOnLines( | |
position + headHeight, | |
-height - headHeight, | |
contactPoint0, | |
contactPoint1 - contactPoint0 | |
); | |
-- Plane push step; just make sure | |
-- that we do some discrete checks. | |
local delta = p - contactPoint; | |
local diff = radius - delta.Magnitude; | |
if(diff >= -1e-3)then | |
local normal = delta.Unit; | |
if(normal ~= normal)then | |
-- Prevent NaN | |
continue; | |
end | |
position += math.max(diff, 0.0) * normal; | |
if(normal.Y > CAPSULE_FLOOR_ANGLE)then | |
-- ! Could have a weird floor angle bug here, | |
-- but I think this should be fine. | |
floor = normal; | |
else | |
velocity = utilConstrain(velocity, normal); | |
end | |
end | |
end | |
self.position = position; | |
self.velocity = velocity; | |
self.floor = floor; | |
end | |
function quakec:getWishDirection() | |
-- Loop through our keymap for the raw input | |
-- direction. | |
local rawDir = Vector3.zero; | |
local input = self.input; | |
for i, keyVec in QUAKEC_KEYMAP_VEC do | |
local mask = bit32.lshift(1, i - 1); | |
if(bit32.btest(input, mask))then | |
rawDir += keyVec; | |
end | |
end | |
local plane = utilQuaternionXZ(CurrentCamera.CFrame.Rotation); | |
if(rawDir:FuzzyEq(Vector3.zero, 1e-3))then | |
-- No input. | |
return Vector3.zero, plane; | |
else | |
-- Use our projection !! | |
local unit = rawDir.Unit; | |
return plane * unit, plane; | |
end | |
end | |
function quakec:getJumpVelocity() | |
-- Custom I guess | |
return Vector3.yAxis * 25; | |
end | |
function quakec:update(dt) | |
self:move(self.velocity * dt); | |
local wishDir, plane = self:getWishDirection(); | |
local velocity = self.velocity; | |
velocity -= PL_GRAVITY * Vector3.yAxis * dt; | |
if(self.floor and self.jumping)then | |
-- Player is jumping | |
velocity *= XZ; | |
velocity += self:getJumpVelocity(); | |
elseif(self.floor)then | |
-- Apply friction | |
local speed = velocity.Magnitude; | |
if(speed > 0.0)then | |
local drop = speed * PL_GROUND_FRICTION * dt; | |
velocity *= math.max(speed - drop, 0.0) / speed; | |
end | |
velocity = quakeAccelerate( | |
wishDir, | |
velocity, | |
PL_GROUND_ACCELERATION, | |
16, | |
dt | |
) * XZ; | |
velocity -= self.floor:Dot(velocity) * Vector3.yAxis; | |
else | |
-- Apply air physics | |
local projection = quakeAccelerate( | |
wishDir, | |
velocity, | |
PL_AIR_ACCELERATION, | |
PL_MAX_AIR, | |
dt | |
) * XZ; | |
velocity = (velocity * Vector3.yAxis) + projection; | |
end | |
self.velocity = velocity; | |
self.rootPart.CFrame = CFrame.new(self.position) * plane; | |
end | |
function quakec:bind() | |
-- Creates our own quick input system | |
local map = {}; | |
for i, key in QUAKEC_KEYMAP do | |
map[key] = bit32.lshift(1, i - 1); | |
end | |
ContextActionService:BindActionAtPriority( | |
"rbx-quakec-move", | |
function(_, state, input) | |
if(state == Enum.UserInputState.Begin)then | |
-- Begin user input, just add it to the bitmask. | |
self.input = bit32.bor(self.input, map[input.KeyCode]); | |
elseif(state == Enum.UserInputState.End)then | |
-- Stop user input, remove it from the bitmask. | |
self.input = bit32.band(self.input, 0xFF - map[input.KeyCode]); | |
end | |
end, | |
false, | |
math.huge, | |
unpack(QUAKEC_KEYMAP) | |
); | |
ContextActionService:BindActionAtPriority( | |
"rbx-quakec-jump", | |
function(_, state, input) | |
if(state == Enum.UserInputState.Begin)then | |
-- Begin jumping input | |
self.jumping = true; | |
elseif(state == Enum.UserInputState.End)then | |
-- Stop jumping input | |
self.jumping = false; | |
end | |
end, | |
false, | |
math.huge, | |
Enum.KeyCode.Space | |
); | |
return self; | |
end | |
function quakec:hook() | |
-- Update physics before the camera does | |
-- so it doesn't jitter. | |
RunService:BindToRenderStep( | |
"rbx-quakec-update", | |
Enum.RenderPriority.Camera.Value - 1, | |
function(dt) | |
self:update(dt); | |
end | |
); | |
return self; | |
end | |
-- Create player | |
quakec.new(Players.LocalPlayer.Character) | |
:bind() | |
:hook(); | |
return quakec; |
Nobonet
commented
Apr 17, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment