Strafe hud
Key Required

Strafe hud

bhopKurvyKurvy 854 views 1.9K executesUpdated Sep 25, 2026
5.0/ 5(1 votes)
10100% Positive
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
#bhop#strafe#surf#hud#keyless

Community Comments (0)

No comments yet for this script.