
Key Required
Strafe hud
Lua Script•1503 lines (47237 chars)
-- Strafe trainer HUD for StrafesNET games
-- Defaults for the preferences menu; anything changed there is saved to PREFS_FILE.
local DEFAULT_PREFS = {
reset = Enum.KeyCode.E, -- clears jump history and averages
menu = Enum.KeyCode.Semicolon, -- the ':' key; Roblox reports it unshifted
scale = 1,
height = 100, -- px from the bottom of the screen to the panel
opacity = 55, -- panel background, %
popups = true,
-- Custom crosshair (xh); replaces the game's cursor crosshair while on.
crosshair = true,
xhStyle = "cross", -- cross, crossdot, x, circle, dot
xhLength = 6,
xhGap = 4,
xhThickness = 2,
xhDynamic = true, -- widen the gap while turning; off keeps the crosshair static
xhSpread = 6, -- extra gap in px at full turn rate
xhRotation = 0,
xhOutline = true,
xhHue = 0,
xhSat = 0,
xhVal = 1,
xhAlpha = 1,
}
local PREFS_FILE = "strafe_trainer_prefs.json"
-- Fixed to match how the game reads movement, so they stay out of the menu.
local TURN_DEADZONE = 0.5 -- rad/s of yaw before a frame counts as "turning"
local SPEED_WINDOW = 0.1 -- seconds of position samples used for speed
local AIR_MARGIN = 0.3 -- studs above the ground baseline that count as airborne
local HISTORY = 10 -- jumps in the rolling average
local RAY_LENGTH = 50
local MIN_STRAFE_SPEED = 2 -- studs/s; slower than this there's no velocity to strafe across
local LATERAL_MIN = 0.5 -- sin of the angle between keys and velocity; under it the keys push along, not across
-- Used when the game's style can't be read: every key allowed, like Autohop.
local FALLBACK_STYLE = { name = "Autohop", keys = { w = 1, a = 1, s = 1, d = 1 } }
local STYLE_KEY = { forward = "w", left = "a", back = "s", right = "d" } -- move -> the game's key field
-- The game's movement keys; the style decides which of them count.
local MOVE_KEYS = { forward = Enum.KeyCode.W, left = Enum.KeyCode.A, back = Enum.KeyCode.S, right = Enum.KeyCode.D }
local env = getgenv()
if env.StrafeTrainer then
pcall(env.StrafeTrainer.destroy)
end
local GuiService = game:GetService("GuiService")
local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local LocalPlayer = Players.LocalPlayer
local COLOR_IDLE = Color3.fromRGB(58, 58, 66)
local COLOR_HELD = Color3.fromRGB(120, 120, 132)
local COLOR_HINT = Color3.fromRGB(52, 110, 70)
local COLOR_GOOD = Color3.fromRGB(70, 215, 120)
local COLOR_BAD = Color3.fromRGB(225, 70, 70)
local COLOR_DIM = Color3.fromRGB(150, 150, 162)
local COLOR_MUTED = Color3.fromRGB(110, 110, 120)
local COLOR_TEXT = Color3.new(1, 1, 1)
local COLOR_BACK = Color3.fromRGB(12, 12, 15)
local TW_BAR = TweenInfo.new(0.12, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local TW_FADE = TweenInfo.new(0.25, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local TW_SETTLE = TweenInfo.new(0.45, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local TW_POP = TweenInfo.new(0.3, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
local TW_DRIFT = TweenInfo.new(0.55, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local TW_SLIDE = TweenInfo.new(0.3, Enum.EasingStyle.Quint, Enum.EasingDirection.Out)
-- Accuracy popups sit just above the panel, lined up with each bar.
local POPUP_X = 74 -- px either side of the panel's centre
local POPUP_GAP = 6 -- px between the panel's top edge and the popup
local POPUP_RISE = 16 -- px the popup drifts up while fading out
local SPREAD_FULL_TURN = 6 -- rad/s of yaw at which the crosshair spread maxes out
local stats = {
speed = 0,
airborne = false,
jumps = 0,
lastSync = nil,
lastGain = nil,
avgSync = nil,
avgGain = nil,
}
local function tween(obj, info, props)
local t = TweenService:Create(obj, info, props)
t:Play()
return t
end
-- Preferences
local KEY_ROWS = {
{ "reset", "reset stats" },
{ "menu", "open menu" },
}
local STEPPERS = {
-- pref = { label, min, max, step, format, wraps }
scale = { "hud scale", 0.5, 2, 0.1, "%.1fx" },
height = { "hud height", 0, 600, 10, "%d" },
opacity = { "hud opacity", 0, 100, 10, "%d%%" },
xhLength = { "length", 1, 24, 1, "%d" },
xhGap = { "gap", 0, 16, 1, "%d" },
xhThickness = { "thickness", 1, 8, 1, "%d" },
xhSpread = { "spread", 0, 16, 1, "%d" },
-- Every style repeats each 90°, so rotation wraps round instead of stopping.
xhRotation = { "rotation", 0, 85, 5, "%d°", true },
}
local UNIT_PREFS = { "xhHue", "xhSat", "xhVal", "xhAlpha" } -- 0-1, set by the wheel and sliders
local XH_STYLES = { "cross", "crossdot", "x", "circle", "dot" }
local prefs = table.clone(DEFAULT_PREFS)
local function loadPrefs()
local ok, saved = pcall(function()
return isfile(PREFS_FILE) and HttpService:JSONDecode(readfile(PREFS_FILE))
end)
if not ok or type(saved) ~= "table" then
return
end
for name, default in DEFAULT_PREFS do
local v = saved[name]
if typeof(default) == "EnumItem" then
local found, key = pcall(function()
return Enum.KeyCode[v]
end)
v = found and key or nil
elseif type(v) ~= type(default) then
v = nil
end
if v ~= nil then
prefs[name] = v
end
end
for name, spec in STEPPERS do
local stepSize = spec[4]
prefs[name] = math.clamp(math.round(prefs[name] / stepSize) * stepSize, spec[2], spec[3])
end
for _, name in UNIT_PREFS do
prefs[name] = math.clamp(prefs[name], 0, 1)
end
if not table.find(XH_STYLES, prefs.xhStyle) then
prefs.xhStyle = DEFAULT_PREFS.xhStyle
end
end
local function savePrefs()
local out = {}
for name, v in prefs do
out[name] = typeof(v) == "EnumItem" and v.Name or v
end
pcall(function()
writefile(PREFS_FILE, HttpService:JSONEncode(out))
end)
end
loadPrefs()
-- GUI
local gui = Instance.new("ScreenGui")
gui.Name = "StrafeTrainer"
gui.ResetOnSpawn = false
gui.IgnoreGuiInset = true
gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
-- Unclipped holder for the panel and its popups, so scale and height move both.
local hud = Instance.new("Frame")
hud.AnchorPoint = Vector2.new(0.5, 1)
hud.Position = UDim2.new(0.5, 0, 1, -prefs.height)
hud.Size = UDim2.fromOffset(168, 44)
hud.BackgroundTransparency = 1
hud.Parent = gui
local hudScale = Instance.new("UIScale")
hudScale.Scale = prefs.scale
hudScale.Parent = hud
local panel = Instance.new("CanvasGroup")
panel.AnchorPoint = Vector2.new(0.5, 1)
panel.Position = UDim2.fromScale(0.5, 1)
panel.Size = UDim2.fromScale(1, 1)
panel.BackgroundColor3 = COLOR_BACK
panel.BackgroundTransparency = 1 - prefs.opacity / 100
panel.BorderSizePixel = 0
panel.GroupTransparency = 1
panel.Parent = hud
Instance.new("UICorner", panel).CornerRadius = UDim.new(0, 7)
local scale = Instance.new("UIScale")
scale.Scale = 0.92
scale.Parent = panel
local stroke = Instance.new("UIStroke")
stroke.Color = Color3.fromRGB(255, 255, 255)
stroke.Transparency = 0.88
stroke.Thickness = 1
stroke.Parent = panel
-- Thin bars flanking the readout, one per turn direction: they light up for the way you're
-- turning and go green or red by whether your keys push that way.
local function makeBar(anchorX, xScale, xOffset)
local bar = Instance.new("Frame")
bar.AnchorPoint = Vector2.new(anchorX, 0.5)
bar.Position = UDim2.new(xScale, xOffset, 0.5, 0)
bar.Size = UDim2.fromOffset(3, 16)
bar.BackgroundColor3 = COLOR_IDLE
bar.BorderSizePixel = 0
bar.Parent = panel
Instance.new("UICorner", bar).CornerRadius = UDim.new(1, 0)
return bar
end
local barLeft = makeBar(0, 0, 9)
local barRight = makeBar(1, 1, -9)
local speedLabel = Instance.new("TextLabel")
speedLabel.AnchorPoint = Vector2.new(0.5, 0)
speedLabel.Position = UDim2.new(0.5, 0, 0, 5)
speedLabel.Size = UDim2.fromOffset(120, 21)
speedLabel.BackgroundTransparency = 1
speedLabel.Font = Enum.Font.GothamBold
speedLabel.TextSize = 20
speedLabel.TextColor3 = COLOR_TEXT
speedLabel.Text = "0.0"
speedLabel.Parent = panel
local statLabel = Instance.new("TextLabel")
statLabel.AnchorPoint = Vector2.new(0.5, 0)
statLabel.Position = UDim2.new(0.5, 0, 0, 27)
statLabel.Size = UDim2.fromOffset(150, 12)
statLabel.BackgroundTransparency = 1
statLabel.Font = Enum.Font.Gotham
statLabel.TextSize = 11
statLabel.TextColor3 = COLOR_DIM
statLabel.RichText = true
statLabel.Text = ""
statLabel.Parent = panel
-- Crosshair
-- One crosshair: four arms, a centre dot and a ring (plus the ring's outline),
-- shown per style. Backs the live crosshair, the editor preview and the style icons.
local function newCrosshair(parent)
local root = Instance.new("Frame")
root.AnchorPoint = Vector2.new(0.5, 0.5)
root.Position = UDim2.fromScale(0.5, 0.5)
root.Size = UDim2.fromOffset(0, 0)
root.BackgroundTransparency = 1
root.Parent = parent
local function part(zIndex, round)
local frame = Instance.new("Frame")
frame.AnchorPoint = Vector2.new(0.5, 0.5)
frame.BorderSizePixel = 0
frame.ZIndex = zIndex
frame.Parent = root
local stroke = Instance.new("UIStroke")
stroke.Color = Color3.new(0, 0, 0)
-- Miter keeps square corners crisp but fills in a rounded frame's stroke.
stroke.LineJoinMode = round and Enum.LineJoinMode.Round or Enum.LineJoinMode.Miter
stroke.Parent = frame
local corner
if round then
corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0.5, 0)
corner.Parent = frame
end
return { frame = frame, stroke = stroke, corner = corner }
end
local xh = { root = root, arms = {} }
for i = 1, 4 do
xh.arms[i] = part(2)
end
xh.dot = part(2, true)
xh.ringOutline = part(1, true)
xh.ring = part(2, true)
xh.ring.frame.BackgroundTransparency = 1
xh.ringOutline.frame.BackgroundTransparency = 1
return xh
end
local function drawCrosshair(xh, cfg)
local t = cfg.thickness
-- Odd widths centre on a pixel and even ones between two, so edges land on whole pixels.
local c = (t % 2) * 0.5
local centre = UDim2.fromOffset(c, c)
local clear = 1 - cfg.alpha
local style = cfg.style
local rot = cfg.rotation + (style == "x" and 45 or 0)
local reach = t / 2 + cfg.gap + cfg.length / 2 -- middle of each arm from the centre
local showArms = style == "cross" or style == "crossdot" or style == "x"
for i, arm in xh.arms do
arm.frame.Visible = showArms
if showArms then
local a = math.rad(rot + (i - 1) * 90)
arm.frame.Position = UDim2.fromOffset(c + math.cos(a) * reach, c + math.sin(a) * reach)
-- Upright arms swap width and height rather than turning 90°, so all four
-- share one Rotation and stay pixel-sharp while it's 0.
arm.frame.Size = i % 2 == 1 and UDim2.fromOffset(cfg.length, t) or UDim2.fromOffset(t, cfg.length)
arm.frame.Rotation = rot
arm.frame.BackgroundColor3 = cfg.color
arm.frame.BackgroundTransparency = clear
arm.stroke.Enabled = cfg.outline
arm.stroke.Transparency = clear
end
end
local showDot = style == "crossdot" or style == "dot"
xh.dot.frame.Visible = showDot
if showDot then
xh.dot.frame.Position = centre
xh.dot.frame.Size = UDim2.fromOffset(t, t)
xh.dot.frame.Rotation = rot
xh.dot.frame.BackgroundColor3 = cfg.color
xh.dot.frame.BackgroundTransparency = clear
local roundDot = style == "dot" and t >= 4
xh.dot.corner.CornerRadius = UDim.new(roundDot and 0.5 or 0, 0)
xh.dot.stroke.LineJoinMode = roundDot and Enum.LineJoinMode.Round or Enum.LineJoinMode.Miter
xh.dot.stroke.Enabled = cfg.outline
xh.dot.stroke.Transparency = clear
end
-- The ring's stroke sits outside its frame, so this size puts the stroke's
-- centre line where the arms' midpoints would be.
local showRing = style == "circle"
local ringSize = 2 * cfg.gap + cfg.length
local outlineSize = math.max(ringSize - 2, 0)
xh.ring.frame.Visible = showRing
xh.ringOutline.frame.Visible = showRing and cfg.outline
if showRing then
xh.ring.frame.Position = centre
xh.ring.frame.Size = UDim2.fromOffset(ringSize, ringSize)
xh.ring.stroke.Thickness = t
xh.ring.stroke.Color = cfg.color
xh.ring.stroke.Transparency = clear
xh.ringOutline.frame.Position = centre
xh.ringOutline.frame.Size = UDim2.fromOffset(outlineSize, outlineSize)
xh.ringOutline.stroke.Thickness = t + 2
xh.ringOutline.stroke.Transparency = clear
end
end
-- Current prefs as a drawable config; spreadFrac (0-1) widens the gap by up to xhSpread px
-- when the crosshair is dynamic.
local function crosshairConfig(spreadFrac)
local spread = prefs.xhDynamic and math.floor(prefs.xhSpread * spreadFrac + 0.5) or 0
return {
style = prefs.xhStyle,
length = prefs.xhLength,
gap = prefs.xhGap + spread,
thickness = prefs.xhThickness,
rotation = prefs.xhRotation,
outline = prefs.xhOutline,
color = Color3.fromHSV(prefs.xhHue, prefs.xhSat, prefs.xhVal),
alpha = prefs.xhAlpha,
}
end
local liveCrosshair = newCrosshair(gui)
liveCrosshair.root.Visible = false
gui.Parent = gethui()
tween(panel, TW_FADE, { GroupTransparency = 0 })
tween(scale, TW_POP, { Scale = 1 })
-- State
local samples = {} -- { {t, pos}, ... } oldest first
local lastYaw = nil
local groundBaseline = nil
local wasAirborne = false
local takeoffSpeed = 0
local turnTime, syncTime = 0, 0
local history = {}
local shownSpeed = 0
local barState = {} -- bar -> last painted state, so we only tween on change
local popups = {} -- side (-1 left, 1 right) -> popup for the hold in progress
local turnRate = 0 -- |yaw rate| from the last frame, drives the crosshair spread
local velocity = Vector2.zero -- horizontal (X, Z) velocity from the position samples
local view = nil -- the game's View module, which knows the style being played
local nextViewSearch = 0
local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Exclude
local function fmtSync(v)
return v and string.format("%d%%", math.floor(v + 0.5)) or "--"
end
local function fmtGain(v)
return v and string.format("%+.1f", v) or "--"
end
local function popupPosition(side, rise)
return UDim2.new(0.5, side * POPUP_X, 0, -POPUP_GAP - rise)
end
local function spawnPopup(side)
local label = Instance.new("TextLabel")
label.AnchorPoint = Vector2.new(0.5, 1)
label.Position = popupPosition(side, -6)
label.Size = UDim2.fromOffset(40, 14)
label.BackgroundTransparency = 1
label.Font = Enum.Font.GothamBold
label.TextSize = 13
label.TextColor3 = COLOR_DIM
label.TextTransparency = 1
label.Text = ""
label.Parent = hud
local popScale = Instance.new("UIScale")
popScale.Scale = 0.6
popScale.Parent = label
local p = { label = label, scale = popScale, turn = 0, sync = 0, shown = false }
popups[side] = p
return p
end
-- Key lifted: float the number up and fade it so the next press takes over.
local function releasePopup(side)
local p = popups[side]
if not p then
return
end
popups[side] = nil
if not p.shown then
p.label:Destroy()
return
end
tween(p.label, TW_DRIFT, {
TextTransparency = 1,
Position = popupPosition(side, POPUP_RISE),
}).Completed:Connect(function()
p.label:Destroy()
end)
end
-- Accuracy for a single hold: share of turning time spent turning toward this key.
-- The popup stays hidden until there's something to score.
local function trackPopup(side, held, inSync, turning, dt)
if not held then
releasePopup(side)
return
end
local p = popups[side] or spawnPopup(side)
if not turning then
return
end
p.turn += dt
if inSync then
p.sync += dt
end
p.label.Text = fmtSync(p.sync / p.turn * 100)
if not p.shown then
p.shown = true
tween(p.label, TW_POP, { TextTransparency = 0, Position = popupPosition(side, 0) })
tween(p.scale, TW_POP, { Scale = 1 })
end
end
local function resetMotion()
table.clear(samples)
lastYaw = nil
wasAirborne = false
turnTime, syncTime = 0, 0
turnRate = 0
for side in popups do
releasePopup(side)
end
end
local function refreshStatLabel()
statLabel.Text = string.format(
'%s %s<font color="#6E6E78"> avg %s %s</font>',
fmtSync(stats.lastSync),
fmtGain(stats.lastGain),
fmtSync(stats.avgSync),
fmtGain(stats.avgGain)
)
end
local function resetStats()
table.clear(history)
stats.jumps = 0
stats.lastSync, stats.lastGain = nil, nil
stats.avgSync, stats.avgGain = nil, nil
groundBaseline = nil
resetMotion()
refreshStatLabel()
-- Quick squash so the reset reads as deliberate.
scale.Scale = 0.9
tween(scale, TW_POP, { Scale = 1 })
stroke.Transparency = 0.4
tween(stroke, TW_SETTLE, { Transparency = 0.88 })
end
refreshStatLabel()
local function getRoot()
local folder = workspace:FindFirstChild("Characters")
local char = folder and folder:FindFirstChild(LocalPlayer.Name) or LocalPlayer.Character
return char and char:FindFirstChild("HumanoidRootPart"), folder
end
local function horizontalVelocity(now, pos)
local last = samples[#samples]
-- A big single-frame jump is a teleport (respawn, reset), not movement.
if last and (pos - last[2]).Magnitude > 100 then
table.clear(samples)
end
table.insert(samples, { now, pos })
while #samples > 2 and now - samples[2][1] >= SPEED_WINDOW do
table.remove(samples, 1)
end
local first = samples[1]
local span = now - first[1]
if span <= 0 then
return velocity
end
local d = pos - first[2]
return Vector2.new(d.X, d.Z) / span
end
-- The style being played, as the game describes it (keys, total, allow_strafe_dir).
-- View is found once through the garbage collector; a failed search retries every couple of seconds.
local function currentStyle()
if not view and os.clock() >= nextViewSearch then
nextViewSearch = os.clock() + 2
local ok, found = pcall(filtergc, "table", { Keys = { "PlaybackContext", "SetPlayer" } }, true)
view = ok and type(found) == "table" and found or nil
end
local ok, info = pcall(function()
return view.PlaybackContext.StyleInfo
end)
return ok and type(info) == "table" and type(info.keys) == "table" and info or FALLBACK_STYLE
end
-- Mirrors the game's CalculateStyleDir: the (X, Z) direction the held keys push you under
-- `style`, or nil when they give no push (disallowed key, wrong combination, nothing held).
local function styleWishDir(style, held, camYaw)
local keys = style.keys
local w, a, s, d = keys.w, keys.a, keys.s, keys.d
local weight = (held.forward and w or 0) + (held.left and a or 0) + (held.back and s or 0) + (held.right and d or 0)
if style.total and style.total ~= weight then
return nil
end
if w == 2 and not held.forward or a == 2 and not held.left or s == 2 and not held.back or d == 2 and not held.right then
return nil
end
local x = ((d ~= 0 and held.right) and 1 or 0) - ((a ~= 0 and held.left) and 1 or 0)
local z = ((s ~= 0 and held.back) and 1 or 0) - ((w ~= 0 and held.forward) and 1 or 0)
if x == 0 and z == 0 then
return nil
end
local c, sn = math.cos(camYaw), math.sin(camYaw)
local dir = Vector2.new(x * c + z * sn, z * c - x * sn).Unit
local allowed = style.allow_strafe_dir
if allowed then
local along = Vector2.new(dir.X * velocity.X + dir.Y * velocity.Y, dir.X * velocity.Y - dir.Y * velocity.X):Dot(allowed)
if not (along == 0 or along < 0 and not held.left or along > 0 and not held.right) then
return nil
end
end
return dir
end
-- The one key a style strafes with (W-only, A-only), or nil if it has several.
local function soleKey(style)
local only = nil
for name, field in STYLE_KEY do
local weight = style.keys[field]
if weight and weight ~= 0 then
if only then
return nil
end
only = name
end
end
return only
end
-- Which way the held keys push you across your velocity: 1 left, -1 right, 0 neither.
local function inputSide(camYaw)
if stats.speed < MIN_STRAFE_SPEED then
return 0
end
local style = currentStyle()
local held = {}
for name in STYLE_KEY do
held[name] = UserInputService:IsKeyDown(MOVE_KEYS[name])
end
local dir, flip = styleWishDir(style, held, camYaw), 1
-- One-key styles gain on the other turn by letting go, so there releasing counts as that side.
local only = soleKey(style)
if not dir and only and not held[only] then
held[only] = true
dir, flip = styleWishDir(style, held, camYaw), -1
end
if not dir then
return 0
end
local lateral = (dir.X * velocity.Y - dir.Y * velocity.X) / stats.speed
if math.abs(lateral) < LATERAL_MIN then
return 0
end
return lateral > 0 and flip or -flip
end
local function isAirborne(root, folder)
rayParams.FilterDescendantsInstances = { folder or root.Parent }
local hit = workspace:Raycast(root.Position, Vector3.new(0, -RAY_LENGTH, 0), rayParams)
if not hit then
return true
end
local dist = root.Position.Y - hit.Position.Y
if not groundBaseline or dist < groundBaseline then
groundBaseline = dist
end
return dist > groundBaseline + AIR_MARGIN
end
local function finishJump(landSpeed)
local sync = turnTime > 0 and (syncTime / turnTime * 100) or nil
local gain = landSpeed - takeoffSpeed
stats.jumps += 1
stats.lastSync = sync
stats.lastGain = gain
table.insert(history, { sync = sync, gain = gain })
if #history > HISTORY then
table.remove(history, 1)
end
local syncSum, syncN, gainSum = 0, 0, 0
for _, h in history do
gainSum += h.gain
if h.sync then
syncSum += h.sync
syncN += 1
end
end
stats.avgGain = gainSum / #history
stats.avgSync = syncN > 0 and syncSum / syncN or nil
refreshStatLabel()
-- Flash the fresh numbers in the colour of the result, then settle back.
statLabel.TextColor3 = gain >= 0 and COLOR_GOOD or COLOR_BAD
tween(statLabel, TW_SETTLE, { TextColor3 = COLOR_DIM })
end
local function paintBar(bar, isCorrect, isWrong, held)
local color, height
if isCorrect then
color = held and COLOR_GOOD or COLOR_HINT
height = held and 26 or 22
elseif isWrong and held then
color, height = COLOR_BAD, 22
else
color = held and COLOR_HELD or COLOR_IDLE
height = held and 20 or 16
end
local last = barState[bar]
if not last or last.color ~= color or last.height ~= height then
barState[bar] = { color = color, height = height }
tween(bar, TW_BAR, { BackgroundColor3 = color, Size = UDim2.fromOffset(3, height) })
end
end
local function step(dt)
local root, folder = getRoot()
if not root or dt <= 0 then
resetMotion()
return
end
local now = os.clock()
velocity = horizontalVelocity(now, root.Position)
stats.speed = velocity.Magnitude
local look = workspace.CurrentCamera.CFrame.LookVector
local yaw = math.atan2(-look.X, -look.Z)
local yawRate = 0
if lastYaw then
local d = (yaw - lastYaw + math.pi) % (2 * math.pi) - math.pi
yawRate = d / dt
end
lastYaw = yaw
turnRate = math.abs(yawRate)
-- Positive yaw rate = turning left.
local turning = math.abs(yawRate) > TURN_DEADZONE
local turnLeft = turning and yawRate > 0
local turnRight = turning and yawRate < 0
-- "Held" per side: the keys are pushing you that way, whichever keys the style uses for it.
local side = inputSide(yaw)
local leftHeld = side == 1
local rightHeld = side == -1
local airborne = isAirborne(root, folder)
stats.airborne = airborne
if airborne and not wasAirborne then
takeoffSpeed = stats.speed
turnTime, syncTime = 0, 0
elseif wasAirborne and not airborne then
finishJump(stats.speed)
end
wasAirborne = airborne
local leftSync = turnLeft and leftHeld
local rightSync = turnRight and rightHeld
if airborne and turning then
turnTime += dt
if leftSync or rightSync then
syncTime += dt
end
end
paintBar(barLeft, turnLeft, turnRight, leftHeld)
paintBar(barRight, turnRight, turnLeft, rightHeld)
trackPopup(-1, prefs.popups and leftHeld, leftSync, turning, dt)
trackPopup(1, prefs.popups and rightHeld, rightSync, turning, dt)
-- Ease the number so it glides instead of flickering digit to digit.
shownSpeed += (stats.speed - shownSpeed) * math.min(dt * 14, 1)
speedLabel.Text = string.format("%.1f", shownSpeed)
end
-- Preferences menu
local MENU_W = 200
local MENU_PAD = 12
local ROW_H = 20
local EDITOR_W = 184
local EDITOR_PAD = 10
local EDITOR_GAP = 6 -- px between the menu and the crosshair editor beside it
local EDITOR_ROW_H = 18
local PAGE_H = 90 -- height of each editor tab's controls; the preview gets the rest
local CONTROL_W = 72 -- every row's control lines up in one right-hand column
local BUTTON_IDLE = 0.94
local BUTTON_HOVER = 0.86
local WHEEL_SIZE = 64
local WHEEL_SPOKES = 90
local TABS = { "style", "size", "colour" }
local MOUSE_STEP = "StrafeTrainerMenu"
local KEY_SYMBOLS = {
Semicolon = ";",
Colon = ":",
Quote = "'",
Comma = ",",
Period = ".",
Slash = "/",
BackSlash = "\\",
LeftBracket = "[",
RightBracket = "]",
Minus = "-",
Equals = "=",
Backquote = "`",
}
local function keyName(key)
return KEY_SYMBOLS[key.Name] or key.Name
end
local menuOpen = false
local editorOpen = false
local activeTab = TABS[1]
local capturing = nil -- pref name waiting for its new key
local savedMouse = nil -- mouse state to put back when the menu closes
local dragUpdate = nil -- fed the mouse position each frame while a slider or the wheel is held
local rowRefreshers = {}
local setEditorOpen, setTab -- defined once the editor exists
local function refreshMenu()
for _, refresh in rowRefreshers do
refresh()
end
end
local function applyVisuals()
tween(hud, TW_BAR, { Position = UDim2.new(0.5, 0, 1, -prefs.height) })
tween(hudScale, TW_BAR, { Scale = prefs.scale })
tween(panel, TW_BAR, { BackgroundTransparency = 1 - prefs.opacity / 100 })
end
local function prefsChanged()
applyVisuals()
savePrefs()
refreshMenu()
end
local function endDrag()
if dragUpdate then
dragUpdate = nil
savePrefs()
end
end
-- A see-through panel in the HUD's style, faded in by setMenuOpen.
local function newPanel(zIndex)
local p = Instance.new("CanvasGroup")
p.BackgroundColor3 = COLOR_BACK
p.BackgroundTransparency = 0.4
p.BorderSizePixel = 0
p.GroupTransparency = 1
p.Visible = false
p.ZIndex = zIndex
p.Parent = gui
Instance.new("UICorner", p).CornerRadius = UDim.new(0, 7)
local s = Instance.new("UIStroke")
s.Color = Color3.fromRGB(255, 255, 255)
s.Transparency = 0.88
s.Thickness = 1
s.Parent = p
-- Sits behind the contents so clicks on the panel never reach the game.
local catcher = Instance.new("TextButton")
catcher.Size = UDim2.fromScale(1, 1)
catcher.BackgroundTransparency = 1
catcher.Text = ""
catcher.AutoButtonColor = false
catcher.ZIndex = 0
catcher.Parent = p
return p, catcher
end
local function newBody(p, pad)
local body = Instance.new("Frame")
body.Position = UDim2.fromOffset(pad, 0)
body.Size = UDim2.new(1, -pad * 2, 1, 0)
body.BackgroundTransparency = 1
body.Parent = p
return body
end
local function menuLabel(text, font, size, color)
local l = Instance.new("TextLabel")
l.BackgroundTransparency = 1
l.Font = font
l.TextSize = size
l.TextColor3 = color
l.TextXAlignment = Enum.TextXAlignment.Left
l.Text = text
return l
end
-- Rows stack downward from `y`; each panel or tab page keeps its own stack.
local function newStack(parent, y, rowH)
return { parent = parent, y = y, rowH = rowH }
end
local function addSection(stack, title)
stack.y += 6
local l = menuLabel(title, Enum.Font.GothamBold, 10, COLOR_MUTED)
l.Position = UDim2.fromOffset(0, stack.y)
l.Size = UDim2.new(1, 0, 0, 14)
l.Parent = stack.parent
stack.y += 14
end
local function addRow(stack, title)
local row = Instance.new("Frame")
row.Position = UDim2.fromOffset(0, stack.y)
row.Size = UDim2.new(1, 0, 0, stack.rowH)
row.BackgroundTransparency = 1
row.Parent = stack.parent
local l = menuLabel(title, Enum.Font.Gotham, 11, COLOR_DIM)
l.Size = UDim2.fromScale(1, 1)
l.Parent = row
stack.y += stack.rowH
return row
end
local function newButton(parent, width, height)
local b = Instance.new("TextButton")
b.Size = UDim2.fromOffset(width, height)
b.BackgroundColor3 = COLOR_TEXT
b.BackgroundTransparency = BUTTON_IDLE
b.BorderSizePixel = 0
b.AutoButtonColor = false
b.Font = Enum.Font.GothamBold
b.TextSize = 11
b.TextColor3 = COLOR_TEXT
b.Text = ""
b.Parent = parent
Instance.new("UICorner", b).CornerRadius = UDim.new(0, 4)
b.MouseEnter:Connect(function()
tween(b, TW_BAR, { BackgroundTransparency = BUTTON_HOVER })
end)
b.MouseLeave:Connect(function()
tween(b, TW_BAR, { BackgroundTransparency = BUTTON_IDLE })
end)
return b
end
-- Flat button hugging the row's right edge, `right` px in from it.
local function addButton(row, width, right)
local b = newButton(row, width, 16)
b.AnchorPoint = Vector2.new(1, 0.5)
b.Position = UDim2.new(1, -right, 0.5, 0)
return b
end
local function bindToggle(button, name)
button.MouseButton1Click:Connect(function()
prefs[name] = not prefs[name]
prefsChanged()
end)
table.insert(rowRefreshers, function()
button.Text = prefs[name] and "on" or "off"
button.TextColor3 = prefs[name] and COLOR_TEXT or COLOR_DIM
end)
return button
end
local function addToggle(stack, label, name)
return bindToggle(addButton(addRow(stack, label), CONTROL_W, 0), name)
end
local function addStepper(stack, name)
local label, min, max, stepSize, fmt, wraps = table.unpack(STEPPERS[name])
local row = addRow(stack, label)
local value = menuLabel("", Enum.Font.GothamBold, 11, COLOR_TEXT)
value.AnchorPoint = Vector2.new(1, 0.5)
value.Position = UDim2.new(1, -16, 0.5, 0)
value.Size = UDim2.fromOffset(CONTROL_W - 32, 16)
value.TextXAlignment = Enum.TextXAlignment.Center
value.Parent = row
local function nudge(dir)
-- Snap to the step so repeated float adds don't drift.
local v = math.round((prefs[name] + dir * stepSize) / stepSize) * stepSize
if wraps then
v %= max + stepSize
end
prefs[name] = math.clamp(v, min, max)
prefsChanged()
end
local minus = addButton(row, 16, CONTROL_W - 16)
minus.Text = "-"
minus.MouseButton1Click:Connect(function()
nudge(-1)
end)
local plus = addButton(row, 16, 0)
plus.Text = "+"
plus.MouseButton1Click:Connect(function()
nudge(1)
end)
table.insert(rowRefreshers, function()
value.Text = string.format(fmt, prefs[name])
end)
return value
end
-- Mouse in the same space as AbsolutePosition, which starts below the top bar
-- even though this gui ignores the inset for layout.
local function guiMouse()
return UserInputService:GetMouseLocation() - GuiService:GetGuiInset()
end
-- Invisible button over a draggable control; holding it feeds `update` the mouse each frame.
local function addDragArea(parent, update)
local hit = Instance.new("TextButton")
hit.Size = UDim2.fromScale(1, 1)
hit.BackgroundTransparency = 1
hit.Text = ""
hit.AutoButtonColor = false
hit.ZIndex = 3
hit.Parent = parent
hit.MouseButton1Down:Connect(function()
dragUpdate = update
update(guiMouse())
end)
return hit
end
-- Main menu
local menu, menuCatcher = newPanel(2)
menu.AnchorPoint = Vector2.new(0.5, 0.5)
menu.Position = UDim2.fromScale(0.5, 0.5)
menuCatcher.Modal = true -- frees the mouse in first person while the menu is up
local menuScale = Instance.new("UIScale")
menuScale.Scale = 0.94
menuScale.Parent = menu
local menuBody = newBody(menu, MENU_PAD)
local title = menuLabel("strafe trainer", Enum.Font.GothamBold, 12, COLOR_TEXT)
title.Position = UDim2.fromOffset(0, 10)
title.Size = UDim2.new(1, 0, 0, 16)
title.Parent = menuBody
local closeHint = menuLabel("", Enum.Font.Gotham, 10, COLOR_MUTED)
closeHint.Position = title.Position
closeHint.Size = title.Size
closeHint.TextXAlignment = Enum.TextXAlignment.Right
closeHint.Parent = menuBody
table.insert(rowRefreshers, function()
closeHint.Text = keyName(prefs.menu) .. " to close"
end)
local menuStack = newStack(menuBody, 26, ROW_H)
addSection(menuStack, "keys")
-- Read-only: the style comes from the game, so switching in game is all it takes.
local styleValue = menuLabel("", Enum.Font.GothamBold, 11, COLOR_TEXT)
styleValue.Size = UDim2.fromScale(1, 1)
styleValue.TextXAlignment = Enum.TextXAlignment.Right
styleValue.Parent = addRow(menuStack, "style")
table.insert(rowRefreshers, function()
local style = currentStyle()
styleValue.Text = style == FALLBACK_STYLE and "autohop?" or string.lower(style.name or "?")
styleValue.TextColor3 = style == FALLBACK_STYLE and COLOR_DIM or COLOR_TEXT
end)
for _, spec in KEY_ROWS do
local name = spec[1]
local b = addButton(addRow(menuStack, spec[2]), CONTROL_W, 0)
b.MouseButton1Click:Connect(function()
-- Clicking again cancels, since Roblox's own menu can swallow Esc.
capturing = capturing ~= name and name or nil
refreshMenu()
end)
table.insert(rowRefreshers, function()
local waiting = capturing == name
b.Text = waiting and "..." or keyName(prefs[name])
b.TextColor3 = waiting and COLOR_DIM or COLOR_TEXT
end)
end
addSection(menuStack, "visuals")
addStepper(menuStack, "scale")
addStepper(menuStack, "height")
addStepper(menuStack, "opacity")
addToggle(menuStack, "key popups", "popups")
local crosshairRow = addRow(menuStack, "crosshair")
bindToggle(addButton(crosshairRow, 34, 38), "crosshair")
local editButton = addButton(crosshairRow, 34, 0)
editButton.MouseButton1Click:Connect(function()
setEditorOpen(not editorOpen)
end)
table.insert(rowRefreshers, function()
editButton.Text = editorOpen and "done" or "edit"
end)
menuStack.y += 6
local defaultsButton = addButton(addRow(menuStack, "defaults"), CONTROL_W, 0)
defaultsButton.Text = "restore"
defaultsButton.MouseButton1Click:Connect(function()
capturing = nil
for name, v in DEFAULT_PREFS do
prefs[name] = v
end
prefsChanged()
end)
local menuHeight = menuStack.y + 10
menu.Size = UDim2.fromOffset(MENU_W, menuHeight)
-- Crosshair editor: attaches to the menu's right side, same height, preview on top.
local editor = newPanel(1)
editor.AnchorPoint = Vector2.new(0, 0.5)
editor.Size = UDim2.fromOffset(EDITOR_W, menuHeight)
local editorBody = newBody(editor, EDITOR_PAD)
local bodyW = EDITOR_W - EDITOR_PAD * 2
local previewH = menuHeight - EDITOR_PAD * 2 - 8 - 22 - PAGE_H
local preview = Instance.new("Frame")
preview.Position = UDim2.fromOffset(0, EDITOR_PAD)
preview.Size = UDim2.new(1, 0, 0, previewH)
preview.BackgroundColor3 = COLOR_TEXT
preview.BackgroundTransparency = 0.96
preview.BorderSizePixel = 0
preview.Parent = editorBody
Instance.new("UICorner", preview).CornerRadius = UDim.new(0, 5)
local previewCrosshair = newCrosshair(preview)
-- Tabs, each with its own page of controls below.
local tabY = EDITOR_PAD + previewH + 8
local pages = {}
for i, tab in TABS do
local b = Instance.new("TextButton")
b.Position = UDim2.new((i - 1) / #TABS, 0, 0, tabY)
b.Size = UDim2.new(1 / #TABS, 0, 0, 16)
b.BackgroundTransparency = 1
b.AutoButtonColor = false
b.Font = Enum.Font.GothamBold
b.TextSize = 11
b.Text = tab
b.Parent = editorBody
b.MouseButton1Click:Connect(function()
setTab(tab)
end)
table.insert(rowRefreshers, function()
b.TextColor3 = activeTab == tab and COLOR_TEXT or COLOR_MUTED
end)
local page = Instance.new("Frame")
page.Position = UDim2.fromOffset(0, tabY + 22)
page.Size = UDim2.new(1, 0, 0, PAGE_H)
page.BackgroundTransparency = 1
page.Visible = tab == activeTab
page.Parent = editorBody
pages[tab] = page
end
-- Short underline that slides across to the active tab.
local function tabLinePosition(tab)
return UDim2.new((table.find(TABS, tab) - 0.5) / #TABS, 0, 0, tabY + 16)
end
local tabLine = Instance.new("Frame")
tabLine.AnchorPoint = Vector2.new(0.5, 0)
tabLine.Position = tabLinePosition(activeTab)
tabLine.Size = UDim2.fromOffset(16, 1)
tabLine.BackgroundColor3 = COLOR_TEXT
tabLine.BackgroundTransparency = 0.3
tabLine.BorderSizePixel = 0
tabLine.Parent = editorBody
function setTab(tab)
activeTab = tab
for name, page in pages do
page.Visible = name == tab
end
tween(tabLine, TW_SLIDE, { Position = tabLinePosition(tab) })
refreshMenu()
end
-- Style page: one icon button per style, then rotation and outline.
local ICON_GAP = 6
local iconW = (bodyW - ICON_GAP * (#XH_STYLES - 1)) / #XH_STYLES
for i, style in XH_STYLES do
local b = newButton(pages.style, iconW, 22)
b.Position = UDim2.fromOffset((i - 1) * (iconW + ICON_GAP), 0)
local ring = Instance.new("UIStroke")
ring.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
ring.Color = COLOR_TEXT
ring.Transparency = 0.6
ring.Parent = b
local icon = newCrosshair(b)
b.MouseButton1Click:Connect(function()
prefs.xhStyle = style
prefsChanged()
end)
table.insert(rowRefreshers, function()
local selected = prefs.xhStyle == style
ring.Enabled = selected
drawCrosshair(icon, {
style = style,
length = 4,
gap = 2,
thickness = style == "dot" and 5 or 2,
rotation = 0,
outline = false,
color = selected and COLOR_TEXT or COLOR_DIM,
alpha = 1,
})
end)
end
local styleStack = newStack(pages.style, 26, EDITOR_ROW_H)
addStepper(styleStack, "xhRotation")
addToggle(styleStack, "outline", "xhOutline")
-- Size page.
local sizeStack = newStack(pages.size, 0, EDITOR_ROW_H)
for _, name in { "xhLength", "xhGap", "xhThickness" } do
addStepper(sizeStack, name)
end
addToggle(sizeStack, "dynamic", "xhDynamic")
local spreadValue = addStepper(sizeStack, "xhSpread")
table.insert(rowRefreshers, function()
-- Spread only applies while dynamic, so grey it out (but keep it) when static.
spreadValue.TextColor3 = prefs.xhDynamic and COLOR_TEXT or COLOR_DIM
end)
-- Colour page: hue/saturation wheel on the left, brightness and opacity sliders beside it.
local wheel = Instance.new("Frame")
wheel.Position = UDim2.fromOffset(0, (PAGE_H - WHEEL_SIZE) / 2)
wheel.Size = UDim2.fromOffset(WHEEL_SIZE, WHEEL_SIZE)
wheel.BackgroundTransparency = 1
wheel.Parent = pages.colour
-- Built from thin spokes, each fading white -> its hue, so it needs no image asset.
-- They're wide enough to overlap at the rim, which leaves no gaps.
local wheelR = WHEEL_SIZE / 2
local spokeW = math.ceil(2 * math.pi * wheelR / WHEEL_SPOKES) + 1
for i = 0, WHEEL_SPOKES - 1 do
local a = i / WHEEL_SPOKES * 2 * math.pi
local spoke = Instance.new("Frame")
spoke.AnchorPoint = Vector2.new(0.5, 0.5)
spoke.Position = UDim2.fromOffset(wheelR + math.cos(a) * wheelR / 2, wheelR + math.sin(a) * wheelR / 2)
spoke.Size = UDim2.fromOffset(wheelR, spokeW)
spoke.Rotation = math.deg(a)
spoke.BackgroundColor3 = COLOR_TEXT
spoke.BorderSizePixel = 0
spoke.Parent = wheel
local g = Instance.new("UIGradient")
g.Color = ColorSequence.new(COLOR_TEXT, Color3.fromHSV(i / WHEEL_SPOKES, 1, 1))
g.Parent = spoke
end
-- Darkens the wheel to match the brightness slider; its stroke tidies the rim.
local wheelShade = Instance.new("Frame")
wheelShade.Size = UDim2.fromScale(1, 1)
wheelShade.BackgroundColor3 = Color3.new(0, 0, 0)
wheelShade.BorderSizePixel = 0
wheelShade.Parent = wheel
Instance.new("UICorner", wheelShade).CornerRadius = UDim.new(0.5, 0)
local wheelRim = Instance.new("UIStroke")
wheelRim.Color = COLOR_TEXT
wheelRim.Transparency = 0.85
wheelRim.Parent = wheelShade
-- Picked colour with a white ring and a dark halo, so it reads on any part of the wheel.
local marker = Instance.new("Frame")
marker.AnchorPoint = Vector2.new(0.5, 0.5)
marker.Size = UDim2.fromOffset(11, 11)
marker.BackgroundTransparency = 1
marker.Parent = wheel
Instance.new("UICorner", marker).CornerRadius = UDim.new(0.5, 0)
local markerHalo = Instance.new("UIStroke")
markerHalo.Color = Color3.new(0, 0, 0)
markerHalo.Transparency = 0.5
markerHalo.Parent = marker
local markerFill = Instance.new("Frame")
markerFill.AnchorPoint = Vector2.new(0.5, 0.5)
markerFill.Position = UDim2.fromScale(0.5, 0.5)
markerFill.Size = UDim2.fromOffset(7, 7)
markerFill.BorderSizePixel = 0
markerFill.Parent = marker
Instance.new("UICorner", markerFill).CornerRadius = UDim.new(0.5, 0)
local markerRing = Instance.new("UIStroke")
markerRing.Color = COLOR_TEXT
markerRing.Thickness = 2
markerRing.Parent = markerFill
addDragArea(wheel, function(mouse)
local d = mouse - (wheel.AbsolutePosition + wheel.AbsoluteSize / 2)
prefs.xhHue = (math.atan2(d.Y, d.X) / (2 * math.pi)) % 1
prefs.xhSat = math.min(d.Magnitude / (wheel.AbsoluteSize.X / 2), 1)
refreshMenu()
end)
local function addSlider(y, caption, get, set)
local x = WHEEL_SIZE + 12
local cap = menuLabel(caption, Enum.Font.Gotham, 10, COLOR_MUTED)
cap.Position = UDim2.fromOffset(x, y)
cap.Size = UDim2.new(1, -x, 0, 12)
cap.Parent = pages.colour
local bar = Instance.new("Frame")
bar.Position = UDim2.fromOffset(x, y + 16)
bar.Size = UDim2.new(1, -x, 0, 6)
bar.BackgroundColor3 = COLOR_TEXT
bar.BorderSizePixel = 0
bar.Parent = pages.colour
Instance.new("UICorner", bar).CornerRadius = UDim.new(0.5, 0)
local gradient = Instance.new("UIGradient")
gradient.Parent = bar
local knob = Instance.new("Frame")
knob.AnchorPoint = Vector2.new(0.5, 0.5)
knob.Size = UDim2.fromOffset(4, 12)
knob.BackgroundColor3 = COLOR_TEXT
knob.BorderSizePixel = 0
knob.Parent = bar
Instance.new("UICorner", knob).CornerRadius = UDim.new(0, 2)
local knobEdge = Instance.new("UIStroke")
knobEdge.Color = Color3.new(0, 0, 0)
knobEdge.Transparency = 0.5
knobEdge.Parent = knob
-- Taller than the bar so it's easy to grab.
local hit = addDragArea(bar, function(mouse)
set(math.clamp((mouse.X - bar.AbsolutePosition.X) / bar.AbsoluteSize.X, 0, 1))
refreshMenu()
end)
hit.Position = UDim2.fromOffset(0, -6)
hit.Size = UDim2.new(1, 0, 1, 12)
table.insert(rowRefreshers, function()
knob.Position = UDim2.new(get(), 0, 0.5, 0)
end)
return gradient
end
-- The two sliders span 54px; centre them beside the wheel.
local sliderY = (PAGE_H - 54) / 2
local brightGradient = addSlider(sliderY, "brightness", function()
return prefs.xhVal
end, function(v)
prefs.xhVal = v
end)
local alphaGradient = addSlider(sliderY + 32, "opacity", function()
return prefs.xhAlpha
end, function(v)
prefs.xhAlpha = math.max(v, 0.1)
end)
alphaGradient.Transparency = NumberSequence.new(1, 0)
table.insert(rowRefreshers, function()
local a = prefs.xhHue * 2 * math.pi
local r = prefs.xhSat * wheelR
marker.Position = UDim2.fromOffset(wheelR + math.cos(a) * r, wheelR + math.sin(a) * r)
local colour = Color3.fromHSV(prefs.xhHue, prefs.xhSat, prefs.xhVal)
markerFill.BackgroundColor3 = colour
wheelShade.BackgroundTransparency = prefs.xhVal
brightGradient.Color = ColorSequence.new(Color3.new(0, 0, 0), Color3.fromHSV(prefs.xhHue, prefs.xhSat, 1))
alphaGradient.Color = ColorSequence.new(colour)
end)
-- Opening slides the menu left by half the editor's width so the pair stays centred.
local function menuShift()
return editorOpen and (EDITOR_GAP + EDITOR_W) / 2 or 0
end
-- Tucked is 16px left, under the menu's edge, so opening slides it out from behind.
local function editorPosition(tucked)
return UDim2.new(0.5, MENU_W / 2 + EDITOR_GAP - menuShift() - (tucked and 16 or 0), 0.5, 0)
end
function setEditorOpen(open)
editorOpen = open
refreshMenu()
tween(menu, TW_SLIDE, { Position = UDim2.new(0.5, -menuShift(), 0.5, 0) })
if open then
editor.Position = editorPosition(true)
editor.Visible = true
tween(editor, TW_SLIDE, { Position = editorPosition(false), GroupTransparency = 0 })
else
tween(editor, TW_SLIDE, { Position = editorPosition(true), GroupTransparency = 1 }).Completed:Connect(function()
if not (menuOpen and editorOpen) then
editor.Visible = false
end
end)
end
end
-- The game's crosshair is its mouse icon while the mouse is locked, so hiding it means
-- switching the icon off, then handing back whatever the game had.
local iconHidden, iconWasEnabled = false, true
local function setGameCrosshairHidden(hide)
if hide then
if not iconHidden then
iconHidden = true
iconWasEnabled = UserInputService.MouseIconEnabled
elseif UserInputService.MouseIconEnabled then
iconWasEnabled = true -- the game turned it back on, e.g. from its own settings
end
UserInputService.MouseIconEnabled = false
elseif iconHidden then
iconHidden = false
UserInputService.MouseIconEnabled = iconWasEnabled
end
end
-- Backup for games that re-lock the mouse every frame despite the modal button.
local function freeMouse()
UserInputService.MouseBehavior = Enum.MouseBehavior.Default
UserInputService.MouseIconEnabled = true
UserInputService.MouseIcon = "" -- plain cursor, not the game's crosshair image
end
local function setMenuOpen(open)
if open == menuOpen then
return
end
menuOpen = open
capturing = nil
endDrag()
refreshMenu()
local panels = editorOpen and { menu, editor } or { menu }
if open then
setGameCrosshairHidden(false) -- so the state saved below is the game's own
savedMouse = { UserInputService.MouseBehavior, UserInputService.MouseIconEnabled, UserInputService.MouseIcon }
RunService:BindToRenderStep(MOUSE_STEP, Enum.RenderPriority.Last.Value, freeMouse)
for _, p in panels do
p.Visible = true
tween(p, TW_FADE, { GroupTransparency = 0 })
end
tween(menuScale, TW_POP, { Scale = 1 })
else
RunService:UnbindFromRenderStep(MOUSE_STEP)
UserInputService.MouseBehavior = savedMouse[1]
UserInputService.MouseIconEnabled = savedMouse[2]
UserInputService.MouseIcon = savedMouse[3]
tween(menuScale, TW_FADE, { Scale = 0.94 })
for _, p in panels do
tween(p, TW_FADE, { GroupTransparency = 1 }).Completed:Connect(function()
if not menuOpen then
p.Visible = false
end
end)
end
end
end
-- Next key press becomes the bind. Esc or a second click cancels; taking a key that's already
-- bound hands that bind this one's old key so nothing ends up doubled.
local function finishCapture(key)
local name = capturing
capturing = nil
if key ~= Enum.KeyCode.Escape and key ~= Enum.KeyCode.Unknown then
for _, spec in KEY_ROWS do
if spec[1] ~= name and prefs[spec[1]] == key then
prefs[spec[1]] = prefs[name]
end
end
prefs[name] = key
savePrefs()
end
refreshMenu()
end
refreshMenu()
-- Per-frame work for the crosshair and menu, alongside the stat tracking in step.
local spreadShown = 0
local function overlayStep(dt)
if dragUpdate then
if UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) then
dragUpdate(guiMouse())
else
endDrag()
end
end
local target = math.min(turnRate / SPREAD_FULL_TURN, 1)
spreadShown += (target - spreadShown) * math.min(dt * 12, 1)
-- Only while the mouse is locked, matching when the game shows its own crosshair.
local live = prefs.crosshair and not menuOpen and UserInputService.MouseBehavior == Enum.MouseBehavior.LockCenter
liveCrosshair.root.Visible = live
if live then
drawCrosshair(liveCrosshair, crosshairConfig(spreadShown))
end
setGameCrosshairHidden(live)
if menuOpen and editorOpen then
-- The camera can't turn with the menu up, so breathe the spread to show it off.
drawCrosshair(previewCrosshair, crosshairConfig(0.5 - 0.5 * math.cos(os.clock() * 2.5)))
end
end
local conn = RunService.RenderStepped:Connect(function(dt)
local ok, err = pcall(step, dt)
if not ok then
stats.lastError = tostring(err)
end
ok, err = pcall(overlayStep, dt)
if not ok then
stats.lastError = tostring(err)
end
end)
local inputConn = UserInputService.InputBegan:Connect(function(input, processed)
if processed or input.UserInputType ~= Enum.UserInputType.Keyboard then
return
end
if capturing then
finishCapture(input.KeyCode)
elseif input.KeyCode == prefs.menu then
setMenuOpen(not menuOpen)
elseif input.KeyCode == prefs.reset then
resetStats()
end
end)
env.StrafeTrainer = {
stats = stats,
prefs = prefs,
reset = resetStats,
destroy = function()
setMenuOpen(false)
setGameCrosshairHidden(false)
conn:Disconnect()
inputConn:Disconnect()
gui:Destroy()
env.StrafeTrainer = nil
end,
}Features & Information
Shows speed, per-jump sync % and gain, and which key matches your turn direction. To reset stats press E (rebindable) menu is ;
might expand upon this if anyone requests features
dc: kurvyz
tg: t.me/Kurvvy
Community Comments (0)
No comments yet for this script.
