What is wrong with my math?

The new issue:


OLD BELOW:

I am attempting to write a physical wheel spinner however the issue is that the pointer is not getting the correct sector that it is over and it is also influenced by weight.

Not really sure why this is. I am using physics with weight however it is not too good so it will just default to the one the point lands on so I suppose you can say it doesn’t really take into account of weight.

The selected one is 1 however it lands on 5 but the script does not end up reflecting this.
I have an offset variable which is used to help calibrate the script to understand that it is at the first option (aka sector 1) and should spin correctly which seems it doesn’t.

Math has never been and will never be my strong suit.

I believe it has to do with the calculation from the following function:


local function getLandedSection(currentWheelAngleRadians)
    if #sectionDefinitions == 0 or calculatedAnglePerSectionRadians <= 0 then
        warn("SpinTheWheel: Cannot determine landed section, definitions or calculatedAnglePerSectionRadians invalid.")
        local fallbackDef = (sectionDefinitions[1] and sectionDefinitions[1]) or {name = "Error"}
        return {definition = fallbackDef, debugAngle = -1}
    end

    -- currentWheelAngleRadians is the physical rotation of the hinge.
    -- The part of the wheel under the pointer was originally at -currentWheelAngleRadians relative to the hinge's zero.
    local wheelAngleUnderPointer_Raw = normalizeAngleRadiansPositive(-currentWheelAngleRadians)

    -- Adjust this raw angle based on the configured offset.
    -- This gives us an angle relative to the *start* of the first logical section.
    local angleRelativeToLogicalZero = normalizeAngleRadiansPositive(
        wheelAngleUnderPointer_Raw - math.rad(CONFIG.WHEEL_ZERO_ANGLE_OFFSET_DEGREES)
    )
    
    local sectionIndexFloat = angleRelativeToLogicalZero / calculatedAnglePerSectionRadians
    local sectionIndex = math.floor(sectionIndexFloat)

    sectionIndex = math.max(0, math.min(sectionIndex, #sectionDefinitions - 1))
    
    local landedSectionDef = sectionDefinitions[sectionIndex + 1] 
    
    if not landedSectionDef then
        warn("SpinTheWheel: Failed to map angle to a section. RawHingeAngle: " .. string.format("%.2f", math.deg(currentWheelAngleRadians)) .. " deg, WheelAngleAtPointer_Raw: " .. string.format("%.2f", math.deg(wheelAngleUnderPointer_Raw)) .. " deg, AngleRelativeToLogicalZero: " .. string.format("%.2f", math.deg(angleRelativeToLogicalZero)) .. " deg, CalcIndex: " .. sectionIndex)
        local fallbackDefOnError = (sectionDefinitions[1] and sectionDefinitions[1]) or {name = "MappingError"}
        return {definition = fallbackDefOnError, debugAngle = angleRelativeToLogicalZero}
    end
    
    return {definition = landedSectionDef, debugAngle = angleRelativeToLogicalZero}
end

Config:

--[[
    =========================================================================
    CONFIGURATION TABLE
    Adjust these values to customize the wheel's behavior.
    =========================================================================
]]
local CONFIG = {
    -- Section Weights (SectionName = WeightValue)
    -- Used to determine the "announced" target section. Does NOT affect physical stopping.
    SECTION_WEIGHTS = {
        ["1"] = 10, ["2"] = 5, ["3"] = 10, ["4"] = 2,
        ["5"] = 10, ["6"] = 5, ["7"] = 10, ["8"] = 1,
	},
	-- added this incase i want to add more sections but am not bothered about the weight of the new sectors.
    DEFAULT_SECTION_WEIGHT = 5, -- Weight for sections not listed in SECTION_WEIGHTS.

    -- Initial Spin Behavior
    INITIAL_SPIN_MIN_ROTATIONS = 5,  -- Minimum full rotations during the initial motor-driven phase.
    INITIAL_SPIN_MAX_ROTATIONS = 10, -- Maximum full rotations during the initial motor-driven phase.
    INITIAL_SPIN_SPEED_RPS = 1.5,    -- Rotations Per Second for the initial motor spin.
    MOTOR_MAX_TORQUE = 100000,       -- Max torque for the HingeConstraint's motor (Nm).

    -- Stopping Precision
    STOP_VELOCITY_THRESHOLD_DPS = 0.5, -- Angular velocity (Degrees Per Second) below which the wheel is considered to be stopping.
    MIN_STOP_DURATION_SECONDS = 2.75,  -- How long the wheel's speed must be below threshold to be considered fully stopped.

    -- For testing, will change this in the furture from a click detector
    CLICK_MAX_ACTIVATION_DISTANCE = 60,


  -- THIS IS FINE PLEASE DO NOT WORRY.
    -- **** IMPORTANT CALIBRATION SETTING ****
    WHEEL_ZERO_ANGLE_OFFSET_DEGREES = 0, 
    -- This is the clockwise angle (in degrees) from the Pointer's direction to the *starting edge* 
    -- of what your script considers the *first section* (e.g., 'Section 1', or the first in sorted order), 
    -- WHEN the `WheelHinge.CurrentAngle` is exactly 0.
    -- Example: 
    --   - If your pointer is fixed at 12 o'clock:
    --   - And the *starting edge* of 'Section 1' is at 12 o'clock when WheelHinge.CurrentAngle = 0, then offset is 0.
    --   - If 'Section 1' *starts* at 3 o'clock when WheelHinge.CurrentAngle = 0, offset is 90.
    --   - If 'Section 1' *starts* at 9 o'clock when WheelHinge.CurrentAngle = 0, offset is 270 (or -90).
    -- Adjust this value until the script correctly reports the section the pointer is over.
}
--[[
    END OF CONFIGURATION TABLE
    =========================================================================
]]
1 Like

Wouldn’t it be much easier to just pick a weighted random number and then spin the wheel so it lands in the chosen sector?

2 Likes

Agreed this is how all games related to rewards via physical systems work (plinko, wheel spins, etc.), doing this via physics would be tedious.

I would suggest messing with the constraint first and seeing if that fixes it, maxxing out force, disabling mass on certain objects, etc.

1 Like

I guess I could do that but wanted to mess around with using a physics orientated system which is influenced by weight.

Ironically doing things physically in Roblox is a huge annoyance because the engine does a bad job of simulating things consistently, especially on real servers, and Roblox is liable to change important behavior out from under you at any time.

1 Like

I am fully aware. I just wanted to try doing something different.

2 Likes

So I did decide to do this yet my math sucks once again so I run into the same issue.

 00:11:59.495  WheelSpinner: Starting Motor Phase. Duration: 2.86s. Target Sector: 5 (202.50 deg / 3.53 rad)  -  Server - WheelSpinner:110
  00:12:02.364  WheelSpinner: Starting Servo Phase. Duration: 1.50s. Targeting Angle: 3.53 rad (202.50 deg)  -  Server - WheelSpinner:120

It has landed on sector 7 instead and only 7. The wheel does start off on the middle of sector 7 and only sector 7

The code:

local function selectWeightedReward()
	local totalWeight = 0
	for _, rewardData in rewards do
		totalWeight = totalWeight + rewardData.Weight
	end

	if totalWeight <= 0 then
		warn("WheelSpinner: Total weight of rewards is zero or negative. Cannot select a reward.")
		return nil
	end

	local randomNumber = math.random() * totalWeight
	local cumulativeWeight = 0
	for _, rewardData in rewards do
		cumulativeWeight = cumulativeWeight + rewardData.Weight
		if randomNumber <= cumulativeWeight then
			return rewardData
		end
	end

	warn("WheelSpinner: Could not select a reward, check weights and random number logic.")
	return rewards[#rewards] 
end

local function spinWheelToReward(rewardData, player)
	if not rewardData or not rewardData.Sector then
		warn("WheelSpinner: Invalid reward data provided for spinning.")
		isSpinning = false
		return
	end

	if rewardData.Sector < 1 or rewardData.Sector > NUMBER_OF_SECTORS then
		warn("WheelSpinner: Reward sector (" .. rewardData.Sector .. ") is out of bounds (1-" .. NUMBER_OF_SECTORS .. ").")
		isSpinning = false
		return
	end

	local degreesPerSector = 360 / NUMBER_OF_SECTORS
	local targetAngleDegrees = (rewardData.Sector - 0.5) * degreesPerSector -- Center of the sector
	local actualTargetAngleRad = math.rad(targetAngleDegrees) -- Final resting angle for the servo

	local initialHingeAngleRad = wheelHinge.CurrentAngle

	print(string.format("WheelSpinner: Selected Reward: '%s' (Sector %d) for %s. Initial Hinge Angle: %.2f rad", 
		rewardData.Name, rewardData.Sector, player.Name, initialHingeAngleRad))

	-- Determine total spin duration
	local totalSpinDuration = MIN_SPIN_DURATION + math.random() * (MAX_SPIN_DURATION - MIN_SPIN_DURATION)
	local motorPhaseDuration = totalSpinDuration - SERVO_TARGETING_DURATION

	if motorPhaseDuration < 0.5 then
		motorPhaseDuration = 0.5 
		warn("WheelSpinner: Motor phase duration was very short, adjusted to 0.5s. Consider increasing MIN_SPIN_DURATION or decreasing SERVO_TARGETING_DURATION.")
	end

	print(string.format("WheelSpinner: Starting Motor Phase. Duration: %.2fs. Target Sector: %d (%.2f deg / %.2f rad)", 
		motorPhaseDuration, rewardData.Sector, targetAngleDegrees, actualTargetAngleRad))

	wheelHinge.ActuatorType = Enum.ActuatorType.Motor
	wheelHinge.AngularVelocity = MOTOR_SPIN_ANGULAR_VELOCITY
	wheelHinge.MotorMaxTorque = SHARED_MAX_TORQUE

	task.wait(motorPhaseDuration)

	print(string.format("WheelSpinner: Starting Servo Phase. Duration: %.2fs. Targeting Angle: %.2f rad (%.2f deg)",
		SERVO_TARGETING_DURATION, actualTargetAngleRad, targetAngleDegrees))

	wheelHinge.ActuatorType = Enum.ActuatorType.Servo
	wheelHinge.AngularSpeed = SERVO_TARGETING_ANGULAR_SPEED 
	wheelHinge.ServoMaxTorque = SHARED_MAX_TORQUE
	wheelHinge.TargetAngle = actualTargetAngleRad

	task.wait(SERVO_TARGETING_DURATION)

	-- Stop any residual force after servo phase, though servo should hold position
	wheelHinge.ActuatorType = Enum.ActuatorType.None 

	print(string.format("WheelSpinner: Spin finished for %s. Awarded: '%s'. Final Hinge Angle: %.2f rad (%.2f deg)", 
		player.Name, rewardData.Name, wheelHinge.CurrentAngle, math.deg(wheelHinge.CurrentAngle)))

	-- TODO: Award handling will be added later on. need to finish other systems first.
	
	isSpinning = false
end

-- Main function to initiate a spin cycle
local function startSpinCycle(player)
	if isSpinning then
		print("WheelSpinner: Wheel is already spinning. " .. player.Name .. " tried to spin.")
		return
	end
	isSpinning = true

	print("WheelSpinner: " .. player.Name .. " is starting a new spin cycle...")
	local chosenReward = selectWeightedReward()
	if chosenReward then
		spinWheelToReward(chosenReward, player)
	else
		warn("WheelSpinner: Failed to select a reward for the spin cycle for " .. player.Name)
		isSpinning = false -- Ensure isSpinning is reset if reward selection fails
	end
end

1 Like