Files
VoidLink/VoidLink/Input/MotionHandler.swift
True Zhuanjia 8f8e86ab4f - on screen button “folder” prototype
- overlapped buttons triggered at touch began
- memory management fixes
- reduce toolbox view & widget layout tool animation
- fast widget profile loading
- toolbox & widget profile selector quick dismiss
2026-01-29 10:06:04 +08:00

386 lines
15 KiB
Swift

//
// MicHandler.swift
// VoidLink
//
// Created by True砖家 on 2025/9/28.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import Foundation
import CoreMotion
@objc public protocol OnScreenWidgetStickMixedInputDelegate: AnyObject {
func mixOnScreenRightStickAndGyroInput(x: CGFloat, y: CGFloat)
func mixOnScreenLeftStickAndGyroInput(x: CGFloat, y: CGFloat)
func gyroMixInputStarted() -> Bool
}
@objc class MotionHandler: NSObject, OnScreenWidgetStickMixedInputDelegate{
public func mixOnScreenRightStickAndGyroInput(x: CGFloat, y: CGFloat) {
rightStickTouchInputX = x
rightStickTouchInputY = y
}
public func mixOnScreenLeftStickAndGyroInput(x: CGFloat, y: CGFloat) {
// rollIntegral = 0
leftStickTouchInputX = x
leftStickTouchInputY = y
}
@objc public func mixPhysicalRightStickAndGyroInput(x: CGFloat, y: CGFloat) {
rightStickPhysicalInputX = synthesizePhysicalStick ? x : 0
rightStickPhysicalInputY = synthesizePhysicalStick ? y : 0
}
@objc public func mixPhysicalLeftStickAndGyroInput(x: CGFloat, y: CGFloat) {
// rollIntegral = 0
leftStickPhysicalInputX = synthesizePhysicalStick ? x : 0
leftStickPhysicalInputY = synthesizePhysicalStick ? y : 0
}
public func gyroMixInputStarted() -> Bool {
return gyroIsWorking
}
private static let sharedInstance = MotionHandler()
@objc class func shared(profile: OSCProfile?) -> MotionHandler {
let sharedInstance = MotionHandler.sharedInstance
guard let profile = profile else { return sharedInstance }
sharedInstance.mapGyroTo = profile.mapGyroTo
sharedInstance.synthesizePhysicalStick = profile.synthesizePhysicalStick
sharedInstance.rollToLeftStick = profile.rollToLeftStick
sharedInstance.yawPitchToRightStick = profile.yawPitchToRightStick
sharedInstance.sensitvityYaw = profile.gyroSensitivityYaw
sharedInstance.sensitvityPitch = profile.gyroSensitivityPitch
sharedInstance.sensitvityRoll = profile.gyroSensitivityRoll
sharedInstance.gyroToStickMinOffset = profile.gyroToStickMinOffset
sharedInstance.onScreenControls = OnScreenControls.shared()
return sharedInstance
}
@objc public var gyroControlStarted: Bool = false
private var gyroIsWorking: Bool = false
private var accelControlStarted: Bool = false
private let motionManager = CMMotionManager()
private var synthesizePhysicalStick:Bool = false
private var mapGyroTo:MapGyroTo = .mapGyroToControllerStick
private var rollToLeftStick:Bool = false
private var yawPitchToRightStick:Bool = false
public var sensitvityYaw:CGFloat = 1.0
public var sensitvityPitch:CGFloat = 1.0
public var sensitvityRoll:CGFloat = 1.0
@objc public var widgetYawFactor:CGFloat = 1.0
@objc public var widgetPitchFactor:CGFloat = 1.0
@objc public var widgetRollFactor:CGFloat = 1.0
@objc public var previousWidgetYawFactor:CGFloat = 1.0
@objc public var previousWidgetPitchFactor:CGFloat = 1.0
@objc public var previousWidgetRollFactor:CGFloat = 1.0
public var gyroStarter:Any?
private var windowScene: Any?
@objc public var onScreenControls: OnScreenControls?
public let stickMaxOffset: CGFloat = 0x7FFE
private var stickInputScale: CGFloat = 35
private var yaw:Double = 0
private var pitch:Double = 0
private var roll:Double = 0
private var rollIntegral:Double = 0
private var isCalibrating: Bool = false
private var sumX: Double = 0
private var sumY: Double = 0
private var sumZ: Double = 0
@objc public var gyroBiasX: Double = 0
@objc public var gyroBiasY: Double = 0
@objc public var gyroBiasZ: Double = 0
@objc public var gyroToStickMinOffset:Double = 0
private var yawBias:Double = 0
private var pitchBias:Double = 0
private var rollBias:Double = 0
private var rightStickTouchInputX:Double = 0
private var rightStickTouchInputY:Double = 0
private var leftStickTouchInputX:Double = 0
private var leftStickTouchInputY:Double = 0
private var rightStickPhysicalInputX:Double = 0
private var rightStickPhysicalInputY:Double = 0
public var gyroToStickOffset:CGVector = CGVector(dx: 0, dy: 0)
private var leftStickPhysicalInputX:Double = 0
private var leftStickPhysicalInputY:Double = 0
var updateInterval: TimeInterval = 1.0 / 120.0 {
didSet {
motionManager.accelerometerUpdateInterval = updateInterval
motionManager.gyroUpdateInterval = updateInterval
}
}
@objc public override init() {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
super.init()
if #available(iOS 13.0, *) {
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
self.windowScene = scene
} else {
// Fallback on earlier versions
}
motionManager.accelerometerUpdateInterval = updateInterval
motionManager.gyroUpdateInterval = updateInterval
}
public func startGyroByOnScreenButton(_ sender: OnScreenWidgetView, yawFactor:CGFloat, pitchFactor:CGFloat, rollFactor:CGFloat){
self.previousWidgetYawFactor = self.widgetYawFactor
self.previousWidgetPitchFactor = self.widgetPitchFactor
self.previousWidgetRollFactor = self.widgetRollFactor
self.widgetYawFactor = yawFactor
self.widgetPitchFactor = pitchFactor
self.widgetRollFactor = rollFactor
print("self.widgetRollFactor \(self.widgetRollFactor)")
if self.gyroStarter == nil {
self.gyroStarter = sender
if sender.motionControlButtonString != "GYROPAUSE" {self.startGyroUpdate()}
}
else if sender.motionControlButtonString == "GYROPAUSE" {
self.startGyroUpdate()
}
}
@objc public func startGyroByControllerButton(){
self.previousWidgetYawFactor = self.widgetYawFactor
self.previousWidgetPitchFactor = self.widgetPitchFactor
self.previousWidgetRollFactor = self.widgetRollFactor
self.widgetYawFactor = 1
self.widgetPitchFactor = 1
self.widgetRollFactor = 1
self.startGyroUpdate()
}
/// 启动传感器数据更新
@objc public func startGyroUpdate() {
if !gyroControlStarted {
gyroControlStarted = true
if motionManager.isGyroAvailable {
motionManager.startGyroUpdates(to: .main) { [weak self] gyroData, _ in
guard let self = self, let data = gyroData else { return }
self.handleGyroData(x: data.rotationRate.x,
y: data.rotationRate.y,
z: data.rotationRate.z)
}
}
}
else {return}
}
@objc public func startAccelUpdate() {
accelControlStarted = true
if motionManager.isAccelerometerAvailable {
motionManager.startAccelerometerUpdates(to: .main) { [weak self] accelData, _ in
guard let self = self, let data = accelData else { return }
self.handleAccelerometerData(x: data.acceleration.x,
y: data.acceleration.y,
z: data.acceleration.z)
}
}
}
/// 停止更新
private var interruptNoneGyroInput:Bool = false
@objc public func stopGyroUpdate(interruptNoneGyroInput:Bool=false, resetLeftStick:Bool=false) {
gyroControlStarted = false
gyroIsWorking = false
if motionManager.isGyroActive{
motionManager.stopGyroUpdates()
}
self.clearGyroInput(interruptNonGyroInput:interruptNoneGyroInput, resetLeftStick:resetLeftStick)
}
@objc public func stopAccelUpdate() {
accelControlStarted = false
if motionManager.isAccelerometerActive {
motionManager.stopAccelerometerUpdates()
}
// self.onScreenControls.clearLeftStickTouchPadFlag()
// self.onScreenControls.clearRightStickTouchPadFlag()
}
// MARK: - 私有方法处理数据
private func handleAccelerometerData(x: Double, y: Double, z: Double) {
// 在这里处理加速度数据,比如计算方向、存储或驱动逻辑
}
private func handleGyroData(x: Double, y: Double, z: Double) {
var yawSource:Double = 0
var pitchSource:Double = 0
var rollSource:Double = 0
/*
let correctedX:Double = x - gyroBiasX
let correctedY:Double = y - gyroBiasY
let correctedZ:Double = z - gyroBiasZ */
if #available(iOS 13.0, *) {
let orientation = (windowScene as! UIWindowScene).interfaceOrientation
switch orientation {
case .landscapeLeft:
yawBias = gyroBiasX
pitchBias = -gyroBiasY
rollBias = -gyroBiasZ
yawSource = x-yawBias
pitchSource = -y-pitchBias
rollSource = -z-rollBias
case .landscapeRight:
yawBias = -gyroBiasX
pitchBias = gyroBiasY
rollBias = -gyroBiasZ
yawSource = -x-yawBias
pitchSource = y-pitchBias
rollSource = -z-rollBias
case .portrait:
yawBias = -gyroBiasY
pitchBias = -gyroBiasX
rollBias = -gyroBiasZ
yawSource = -y-yawBias
pitchSource = -x-pitchBias
rollSource = -z-rollBias
case .portraitUpsideDown:
yawBias = gyroBiasY
pitchBias = gyroBiasX
rollBias = -gyroBiasZ
yawSource = y-yawBias
pitchSource = x-pitchBias
rollSource = -z-rollBias
default:
yawBias = gyroBiasX
pitchBias = -gyroBiasY
rollBias = -gyroBiasZ
yawSource = x-yawBias
pitchSource = -y-pitchBias
rollSource = -z-rollBias
}
} else {
let orientation = UIApplication.shared.statusBarOrientation
if orientation.isLandscape {
yawBias = gyroBiasX
pitchBias = -gyroBiasY
rollBias = -gyroBiasZ
yawSource = x-yawBias
pitchSource = -y-pitchBias
rollSource = -z-rollBias
} else if orientation.isPortrait {
yawBias = -gyroBiasY
pitchBias = -gyroBiasX
rollBias = -gyroBiasZ
yawSource = -y-yawBias
pitchSource = -x-pitchBias
rollSource = -z-rollBias
}
}
if !gyroControlStarted {
self.clearGyroInput(interruptNonGyroInput: interruptNoneGyroInput)
print("Gyro: stopped")
return
}
gyroIsWorking = true
if mapGyroTo == MapGyroTo.mapGyroToMouse {
yaw = yawSource*sensitvityYaw*widgetYawFactor*30
pitch = pitchSource*sensitvityPitch*widgetPitchFactor*30
LiSendMouseMoveEvent(Int16(yaw),Int16(pitch))
}
//guard let onScreenControls = onScreenControls else { return }
if mapGyroTo == MapGyroTo.mapGyroToControllerStick {
if yawPitchToRightStick {
gyroToStickOffset.dx = gyroInputToStickInput(input:yawSource*sensitvityYaw*widgetYawFactor*10)
yaw = rightStickTouchInputX + rightStickPhysicalInputX + gyroToStickOffset.dx
yaw = self.clampStickInput(input: yaw)
gyroToStickOffset.dy = -gyroInputToStickInput(input:pitchSource*sensitvityPitch*widgetPitchFactor*10)
pitch = rightStickTouchInputY + rightStickPhysicalInputY + gyroToStickOffset.dy
pitch = self.clampStickInput(input: pitch)
let offsetVector = ControllerUtil.compensated(offsetVector: CGVector(dx: yaw, dy: pitch), minOffset: gyroToStickMinOffset)
onScreenControls?.sendRightStickTouchPadEvent(offsetVector.dx, offsetVector.dy)
}
if rollToLeftStick {
roll = gyroInputToStickInput(input:rollSource*sensitvityRoll*widgetRollFactor*0.2)
rollIntegral = rollIntegral + roll
let mixedLeftStickOffsetX = self.clampStickInput(input: rollIntegral+leftStickTouchInputX+leftStickPhysicalInputX)
let mixedLeftStickOffsetY = self.clampStickInput(input: leftStickTouchInputY+leftStickPhysicalInputY)
let offsetVector = ControllerUtil.compensated(offsetVector: CGVector(dx: mixedLeftStickOffsetX, dy: mixedLeftStickOffsetY), minOffset: gyroToStickMinOffset)
onScreenControls?.sendLeftStickTouchPadEvent(offsetVector.dx, offsetVector.dy)
}
}
}
private func clearGyroInput(interruptNonGyroInput:Bool, resetLeftStick:Bool=false){
// guard let onScreenControls = onScreenControls else { return }
if yawPitchToRightStick{
onScreenControls?.sendRightStickTouchPadEvent(rightStickPhysicalInputX+rightStickTouchInputX-yawBias, rightStickPhysicalInputY+rightStickTouchInputY-pitchBias)
}
if rollToLeftStick{
if resetLeftStick {
rollIntegral = 0
onScreenControls?.sendLeftStickTouchPadEvent(leftStickPhysicalInputX+leftStickTouchInputX-rollBias,leftStickPhysicalInputY+leftStickTouchInputY)
}
}
if(interruptNonGyroInput){
onScreenControls?.clearLeftStickTouchPadFlag()
onScreenControls?.clearRightStickTouchPadFlag()
}
}
private func clampStickInput(input: CGFloat) -> CGFloat{
return fmax(fmin(input, stickMaxOffset),-stickMaxOffset)
}
private func gyroInputToStickInput(input: CGFloat) -> CGFloat{
return self.clampStickInput(input: stickMaxOffset * input / 8)
}
@objc public func calibrateGyroBias(duration: TimeInterval = 5.0, completion: @escaping () -> Void) {
guard motionManager.isGyroAvailable else { return }
isCalibrating = true
var sumX = 0.0, sumY = 0.0, sumZ = 0.0
var sampleCount = 0
motionManager.startGyroUpdates(to: .main) { [weak self] gyroData, _ in
guard let self = self, self.isCalibrating, let data = gyroData else { return }
sumX += data.rotationRate.x
sumY += data.rotationRate.y
sumZ += data.rotationRate.z
sampleCount += 1
}
// 5秒后计算平均值
DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
guard let self = self else { return }
self.motionManager.stopGyroUpdates()
if sampleCount > 0 {
gyroBiasX = sumX / Double(sampleCount)
gyroBiasY = sumY / Double(sampleCount)
gyroBiasZ = sumZ / Double(sampleCount)
}
self.isCalibrating = false
completion()
}
}
}