Description
Shows speed, per-jump sync % and gain, and which key matches your turn. Press E to reset stats and ; to open the menu. contact kurvyz on dc or t.me/Kurvvy for game/feature requests
GAMEbhop
UPDATEDOct 10, 2026
ACCESSKeyless
PLATFORMDesktop & Mobile
Script source Luau
-- [[ BloxScripts Risk Notice ]]
-- This script has not been verified by BloxScripts.fun. Deal with caution.
--
-- Stay safe:
-- • Never log in on unofficial Roblox sites or lookalike domains.
-- • Real Roblox links use roblox.com (check the .com ending).
-- • Treat fake Roblox login / "claim reward" pages as phishing.
-- [[ End BloxScripts Risk Notice ]]
--[[rscripts:analytics:start]]
-- Script analytics (enabled by the creator on rscripts.net).
-- Runs in its own thread and cannot affect the script below.
task.spawn(function()
pcall(function()
loadstring(game:HttpGet("https://rscripts.net/api/telemetry/v2/client.lua?s=6ab2e1ea939f36e908eb90e7"))()
end)
end)
--[[rscripts:analytics:end]]
-- 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,
}
Review the source before using it. Executions count successful copies, capped at 10 per user and network per script in 24 hours.
Community discussion
0Start the conversation.


Your community, in one place