-- 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 avg %s %s', 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, }