- 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
386 lines
15 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|