// // AbstractGamepadView.swift // VoidLink // // Created by True砖家 on 2026/3/25. // Copyright © 2026 True砖家 @ Bilibili. All rights reserved. // import SwiftUI import Combine @available(iOS 13.0, *) struct DPadHighlight: OptionSet { let rawValue: Int static let up = DPadHighlight(rawValue: 1 << 0) static let down = DPadHighlight(rawValue: 1 << 1) static let left = DPadHighlight(rawValue: 1 << 2) static let right = DPadHighlight(rawValue: 1 << 3) static let all: DPadHighlight = [.up, .down, .left, .right] static let none: DPadHighlight = [] } @available(iOS 13.0, *) enum StickHighlightMode: Equatable { case none case outerOnly case thumbOnly case both } @available(iOS 13.0, *) enum TriggerHighlightMode: Equatable { case none case button case pad } @available(iOS 13.0, *) enum DS4TouchHighlightMode: Equatable { case none case pad case button } @available(iOS 13.0, *) enum GamepadType: Equatable { case xbox case ps } @available(iOS 13.0, *) enum GamepadToggleTarget: Hashable { case a, b, x, y case select, start, home case leftStick, rightStick case leftShoulder, rightShoulder case leftTrigger, rightTrigger case ds4Touchpad var widgets: [GamepadWidget] { switch self { case .a: return [.a] case .b: return [.b] case .x: return [.x] case .y: return [.y] case .select: return [.select] case .start: return [.start] case .home: return [.home] case .leftStick: return [.ls, .lswheel, .lsPad, .lsvPad] case .rightStick: return [.rs, .rsvPad, .rsPad, .rsWheel] case .leftShoulder: return [.leftShoulder] case .rightShoulder: return [.rightShoulder] case .leftTrigger: return [.leftTrigger, .ltPad] case .rightTrigger: return [.rightTrigger, .rtPad] case .ds4Touchpad: return [.ds4Touchpad, .ds4TouchpadButton] } } } @available(iOS 13.0, *) enum GamepadWidget: Hashable { case a, b, x, y case select, start, home case dPad, up, down, left, right case ls, lsPad, lswheel, lsvPad, rs, rsvPad, rsPad, rsWheel case leftShoulder, rightShoulder case leftTrigger, ltPad, rightTrigger, rtPad case ds4Touchpad case ds4TouchpadButton var cmd: String { switch self { case .a: return "OSCA" case .b: return "OSCB" case .x: return "OSCX" case .y: return "OSCY" case .select: return "OSCSELECT" case .start: return "OSCSTART" case .home: return "OSCHOME" case .dPad: return "DPAD" case .up: return "OSCUP" case .down: return "OSCDOWN" case .left: return "OSCLEFT" case .right: return "OSCRIGHT" case .ls: return "LS" case .lswheel: return "LSWHEEL" case .lsPad: return "LSPAD" case .lsvPad: return "LSVPAD" case .rs: return "RS" case .rsPad: return "RSPAD" case .rsvPad: return "RSVPAD" case .rsWheel: return "RSWHEEL" case .leftShoulder: return "LB" case .rightShoulder: return "RB" case .leftTrigger: return "LT" case .ltPad: return "LTPAD" case .rightTrigger: return "RT" case .rtPad: return "RTPAD" case .ds4Touchpad: return "DS4TOUCH" case .ds4TouchpadButton: return "DS4TCHBTN" } } var description: String { switch self { case .a: return LocalizationHelper.localizedString(forKey: "Gamepad A") case .b: return LocalizationHelper.localizedString(forKey: "Gamepad B") case .x: return LocalizationHelper.localizedString(forKey: "Gamepad X") case .y: return LocalizationHelper.localizedString(forKey: "Gamepad Y") case .select: return LocalizationHelper.localizedString(forKey: "Select") case .start: return LocalizationHelper.localizedString(forKey: "Start") case .home: return LocalizationHelper.localizedString(forKey: "Gamepad Home") case .dPad: return LocalizationHelper.localizedString(forKey: "Dpad") case .up: return LocalizationHelper.localizedString(forKey: "Gamepad Up") case .down: return LocalizationHelper.localizedString(forKey: "Gamepad Down") case .left: return LocalizationHelper.localizedString(forKey: "Gamepad Left") case .right: return LocalizationHelper.localizedString(forKey: "Gamepad Right") case .ls: return LocalizationHelper.localizedString(forKey: "LS/L3 button") case .lswheel: return LocalizationHelper.localizedString(forKey: "Left stick wheel") case .lsPad: return LocalizationHelper.localizedString(forKey: "Displacement-based left stick pad") case .lsvPad: return LocalizationHelper.localizedString(forKey: "Velocity-based left stick pad") case .rs: return LocalizationHelper.localizedString(forKey: "RS/R3 button") case .rsPad: return LocalizationHelper.localizedString(forKey: "Displacement-based right stick pad") case .rsvPad: return LocalizationHelper.localizedString(forKey: "Velocity-based right stick pad") case .rsWheel: return LocalizationHelper.localizedString(forKey: "Right stick wheel") case .leftShoulder: return LocalizationHelper.localizedString(forKey: "LB/L1") case .rightShoulder: return LocalizationHelper.localizedString(forKey: "RB/R1") case .leftTrigger: return LocalizationHelper.localizedString(forKey: "LT/L2 button") case .ltPad: return LocalizationHelper.localizedString(forKey: "Left trigger pad") case .rightTrigger: return LocalizationHelper.localizedString(forKey: "RT/R2 button") case .rtPad: return LocalizationHelper.localizedString(forKey: "Right trigger pad") case .ds4Touchpad: return LocalizationHelper.localizedString(forKey: "DS4 touchpad") case .ds4TouchpadButton: return LocalizationHelper.localizedString(forKey: "DS4 touchpad button") } } } @available(iOS 13.0, *) enum GamepadMetricsProfile { case standard case picker case overlay } @available(iOS 13.0, *) struct AbstractGamepadView: View { let gamepadType: GamepadType let metricsProfile: GamepadMetricsProfile let clusterGapAdjustmentRatio: CGFloat let upperPrimaryClusterVerticalAdjustmentRatio: CGFloat let centerCompressionRatio: CGFloat let panelHorizontalInsetRatio: CGFloat let triggerStubSpacingAdjustmentRatio: CGFloat let rightShoulderHorizontalOffsetRatio: CGFloat let thumbPurpleStrength: CGFloat let triggerPurpleStrength: CGFloat var liveSnapshot: GamepadOverlaySnapshot? = nil var canSelectCommand: ((String) -> Bool)? = nil var isCommandSelected: ((String) -> Bool)? = nil var onCommandSelected: ((String) -> Void)? = nil var onCommandDeselected: ((String) -> Void)? = nil var resetToken: Int = 0 var selectionSyncToken: Int = 0 var externalDeselectionCommand: String? = nil var externalDeselectionToken: Int = 0 init( gamepadType: GamepadType = .xbox, metricsProfile: GamepadMetricsProfile = .standard, clusterGapAdjustmentRatio: CGFloat = 0, upperPrimaryClusterVerticalAdjustmentRatio: CGFloat = 0, centerCompressionRatio: CGFloat = 0, panelHorizontalInsetRatio: CGFloat = 0, triggerStubSpacingAdjustmentRatio: CGFloat = 0, rightShoulderHorizontalOffsetRatio: CGFloat = 0, thumbPurpleStrength: CGFloat = 1, triggerPurpleStrength: CGFloat = 1, liveSnapshot: GamepadOverlaySnapshot? = nil, canSelectCommand: ((String) -> Bool)? = nil, isCommandSelected: ((String) -> Bool)? = nil, onCommandSelected: ((String) -> Void)? = nil, onCommandDeselected: ((String) -> Void)? = nil, resetToken: Int = 0, selectionSyncToken: Int = 0, externalDeselectionCommand: String? = nil, externalDeselectionToken: Int = 0 ) { self.gamepadType = gamepadType self.metricsProfile = metricsProfile self.clusterGapAdjustmentRatio = clusterGapAdjustmentRatio self.upperPrimaryClusterVerticalAdjustmentRatio = upperPrimaryClusterVerticalAdjustmentRatio self.centerCompressionRatio = centerCompressionRatio self.panelHorizontalInsetRatio = panelHorizontalInsetRatio self.triggerStubSpacingAdjustmentRatio = triggerStubSpacingAdjustmentRatio self.rightShoulderHorizontalOffsetRatio = rightShoulderHorizontalOffsetRatio self.thumbPurpleStrength = thumbPurpleStrength self.triggerPurpleStrength = triggerPurpleStrength self.liveSnapshot = liveSnapshot self.canSelectCommand = canSelectCommand self.isCommandSelected = isCommandSelected self.onCommandSelected = onCommandSelected self.onCommandDeselected = onCommandDeselected self.resetToken = resetToken self.selectionSyncToken = selectionSyncToken self.externalDeselectionCommand = externalDeselectionCommand self.externalDeselectionToken = externalDeselectionToken } @SwiftUI.State private var activeButtons: Set = [] @SwiftUI.State private var dpadHighlight: DPadHighlight = .none @SwiftUI.State private var dpadWholePressed = false @SwiftUI.State private var leftStickHighlight: StickHighlightMode = .none @SwiftUI.State private var rightStickHighlight: StickHighlightMode = .none @SwiftUI.State private var pendingWidgetOptions: [GamepadWidget] = [] @SwiftUI.State private var showWidgetPicker = false @SwiftUI.State private var pickerSourceIsDPad = false @SwiftUI.State private var leftTriggerHighlight: TriggerHighlightMode = .none @SwiftUI.State private var rightTriggerHighlight: TriggerHighlightMode = .none @SwiftUI.State private var ds4TouchHighlight: DS4TouchHighlightMode = .none @SwiftUI.State private var lastAppliedResetToken: Int = 0 @SwiftUI.State private var lastAppliedSelectionSyncToken: Int = 0 @SwiftUI.State private var lastAppliedExternalDeselectionToken: Int = 0 static var tappedWidgets: [GamepadWidget] = [] static var selectedCmd: String = "" private let stickLiveDeadzone: CGFloat = 0.08 private struct Layout { let aspectRatio: CGFloat let bodyCornerRadiusRatio: CGFloat let bodyShadowYOffsetRatio: CGFloat let innerStrokeInsetRatio: CGFloat let radialHighlightEndRadiusRatio: CGFloat let innerContentScale: CGFloat let ds4TouchpadWidthRatio: CGFloat let ds4TouchpadHeightRatio: CGFloat let ds4TouchpadCornerRatio: CGFloat let topTriggerStubHorizontalPaddingRatio: CGFloat let topTriggerStubCenterYRatio: CGFloat let topTriggerStubCenterXRatio: CGFloat let topTriggerStubWidthRatio: CGFloat let topTriggerStubHeightRatio: CGFloat let topTriggerStubSpacingRatio: CGFloat let shoulderBarWidthRatio: CGFloat let shoulderBarHeightRatio: CGFloat let shoulderBarInsetFromSideRatio: CGFloat let shoulderBarCenterYRatio: CGFloat let leftStickSizeRatio: CGFloat let rightStickSizeRatio: CGFloat let dpadSizeRatio: CGFloat let abxySizeRatio: CGFloat let smallTopButtonDiameterRatio: CGFloat let lowerControlCenterYRatio: CGFloat let lowerControlHalfSpacingRatio: CGFloat let topButtonCenterYRatio: CGFloat let leftStickCenterXRatio: CGFloat let leftStickCenterYRatio: CGFloat let homeCenterXRatio: CGFloat let homeCenterYRatio: CGFloat static let standard = Layout( aspectRatio: 1.82, bodyCornerRadiusRatio: 0.3, bodyShadowYOffsetRatio: 0.035, innerStrokeInsetRatio: 0.018, radialHighlightEndRadiusRatio: 0.48, innerContentScale: 1.13, ds4TouchpadWidthRatio: 0.2, ds4TouchpadHeightRatio: 0.135, ds4TouchpadCornerRatio: 0.30, topTriggerStubHorizontalPaddingRatio: 0.11, topTriggerStubCenterYRatio: -0.236, topTriggerStubCenterXRatio: 0.008, topTriggerStubWidthRatio: 0.1062, topTriggerStubHeightRatio: 0.0670, topTriggerStubSpacingRatio: 0.4336, shoulderBarWidthRatio: 0.24, shoulderBarHeightRatio: 0.04, shoulderBarInsetFromSideRatio: 0.15, shoulderBarCenterYRatio: -0.49, leftStickSizeRatio: 0.17, rightStickSizeRatio: 0.17, dpadSizeRatio: 0.17, abxySizeRatio: 0.19, smallTopButtonDiameterRatio: 0.053, lowerControlCenterYRatio: 0.16, lowerControlHalfSpacingRatio: 0.16, topButtonCenterYRatio: -0.24, leftStickCenterXRatio: -0.28, leftStickCenterYRatio: -0.07, homeCenterXRatio: 0.3, homeCenterYRatio: 0.267 ) static let overlay = Layout( aspectRatio: 1.82, bodyCornerRadiusRatio: 0.3, bodyShadowYOffsetRatio: 0.035, innerStrokeInsetRatio: 0.018, radialHighlightEndRadiusRatio: 0.48, innerContentScale: 1.11, ds4TouchpadWidthRatio: 0.2, ds4TouchpadHeightRatio: 0.135, ds4TouchpadCornerRatio: 0.30, topTriggerStubHorizontalPaddingRatio: 0.11, topTriggerStubCenterYRatio: -0.236, topTriggerStubCenterXRatio: 0.008, topTriggerStubWidthRatio: 0.1062, topTriggerStubHeightRatio: 0.0670, topTriggerStubSpacingRatio: 0.4336, shoulderBarWidthRatio: 0.24, shoulderBarHeightRatio: 0.04, shoulderBarInsetFromSideRatio: 0.06, shoulderBarCenterYRatio: -0.49, leftStickSizeRatio: 0.205, rightStickSizeRatio: 0.205, dpadSizeRatio: 0.205, abxySizeRatio: 0.225, smallTopButtonDiameterRatio: 0.064, lowerControlCenterYRatio: 0.17, lowerControlHalfSpacingRatio: 0.235, topButtonCenterYRatio: -0.235, leftStickCenterXRatio: -0.355, leftStickCenterYRatio: -0.02, homeCenterXRatio: 0.36, homeCenterYRatio: 0.265 ) static let picker = Layout( aspectRatio: 1.82, bodyCornerRadiusRatio: 0.3, bodyShadowYOffsetRatio: 0.035, innerStrokeInsetRatio: 0.018, radialHighlightEndRadiusRatio: 0.48, innerContentScale: 1.13, ds4TouchpadWidthRatio: 0.2, ds4TouchpadHeightRatio: 0.135, ds4TouchpadCornerRatio: 0.30, topTriggerStubHorizontalPaddingRatio: 0.11, topTriggerStubCenterYRatio: -0.236, topTriggerStubCenterXRatio: 0.008, topTriggerStubWidthRatio: 0.1062, topTriggerStubHeightRatio: 0.0670, topTriggerStubSpacingRatio: 0.402, shoulderBarWidthRatio: 0.24, shoulderBarHeightRatio: 0.04, shoulderBarInsetFromSideRatio: 0.074, shoulderBarCenterYRatio: -0.49, leftStickSizeRatio: 0.17, rightStickSizeRatio: 0.17, dpadSizeRatio: 0.17, abxySizeRatio: 0.19, smallTopButtonDiameterRatio: 0.053, lowerControlCenterYRatio: 0.16, lowerControlHalfSpacingRatio: 0.16, topButtonCenterYRatio: -0.24, leftStickCenterXRatio: -0.28, leftStickCenterYRatio: -0.07, homeCenterXRatio: 0.3, homeCenterYRatio: 0.267 ) } private var layout: Layout { switch metricsProfile { case .standard: return .standard case .picker: return .picker case .overlay: return .overlay } } private func toggle(_ target: GamepadToggleTarget) { if activeButtons.contains(target) { if target.widgets.count == 1, let widget = target.widgets.first { if canSelectCommand?(widget.cmd) == false { return } AbstractGamepadView.selectedCmd = widget.cmd onCommandSelected?(widget.cmd) return } activeButtons.remove(target) for widget in target.widgets { onCommandDeselected?(widget.cmd) } } else { activeButtons.insert(target) let didAccept = handleTappedWidgets(target.widgets, sourceIsDPad: false) if !didAccept { activeButtons.remove(target) } } } @discardableResult private func handleTappedWidgets(_ widgets: [GamepadWidget], sourceIsDPad: Bool) -> Bool { AbstractGamepadView.tappedWidgets = widgets pickerSourceIsDPad = sourceIsDPad print(AbstractGamepadView.tappedWidgets) guard !widgets.isEmpty else { return false } if widgets.count == 1, let widget = widgets.first { return applySelectedWidget(widget, sourceIsDPad: sourceIsDPad) } else { pendingWidgetOptions = widgets showWidgetPicker = true return true } } private func nextStickHighlightMode( current: StickHighlightMode, selected widget: GamepadWidget, outerWidget: GamepadWidget, thumbWidgets: [GamepadWidget] ) -> StickHighlightMode { if widget == outerWidget { switch current { case .thumbOnly, .both: return .both case .none, .outerOnly: return .outerOnly } } if thumbWidgets.contains(widget) { switch current { case .outerOnly: // 只有“先按键,再选 pad/thumb”才允许两块都亮 return .both case .none, .thumbOnly, .both: // 先选过 pad/thumb,或者已经是 both,再选 pad/thumb,都不升级为 both return .thumbOnly } } return current } private func applyStickSelection(_ widget: GamepadWidget) { switch widget { case .ls, .lsPad, .lswheel, .lsvPad: leftStickHighlight = nextStickHighlightMode( current: leftStickHighlight, selected: widget, outerWidget: .ls, thumbWidgets: [.lsPad, .lswheel, .lsvPad] ) if widget == .ls, isCommandSelected?(GamepadWidget.lsPad.cmd) == true || isCommandSelected?(GamepadWidget.lsvPad.cmd) == true { leftStickHighlight = .both } case .rs, .rsPad, .rsvPad, .rsWheel: rightStickHighlight = nextStickHighlightMode( current: rightStickHighlight, selected: widget, outerWidget: .rs, thumbWidgets: [.rsvPad, .rsPad, .rsWheel] ) if widget == .rs, isCommandSelected?(GamepadWidget.rsPad.cmd) == true || isCommandSelected?(GamepadWidget.rsvPad.cmd) == true { rightStickHighlight = .both } default: break } } @discardableResult private func applySelectedWidget(_ widget: GamepadWidget, sourceIsDPad: Bool) -> Bool { if canSelectCommand?(widget.cmd) == false { return false } AbstractGamepadView.selectedCmd = widget.cmd onCommandSelected?(widget.cmd) if sourceIsDPad { applyDPadSelection(widget) } applyStickSelection(widget) applyTriggerSelection(widget) applyDS4TouchSelection(widget) // print("selectedCmd =", AbstractGamepadView.selectedCmd) return true } private func applyTriggerSelection(_ widget: GamepadWidget) { switch widget { case .leftTrigger: leftTriggerHighlight = .button case .ltPad: leftTriggerHighlight = .pad case .rightTrigger: rightTriggerHighlight = .button case .rtPad: rightTriggerHighlight = .pad default: break } } private func selectWidget(_ widget: GamepadWidget) { let didSelect = applySelectedWidget(widget, sourceIsDPad: pickerSourceIsDPad) if !didSelect { AbstractGamepadView.tappedWidgets = [] } pendingWidgetOptions.removeAll() showWidgetPicker = false } private func applyDS4TouchSelection(_ widget: GamepadWidget) { switch widget { case .ds4Touchpad: ds4TouchHighlight = .pad case .ds4TouchpadButton: ds4TouchHighlight = .button default: break } } private func resetCurrentWidgetPickerSelection() { for widget in pendingWidgetOptions { onCommandDeselected?(widget.cmd) } clearHighlights(for: pendingWidgetOptions) AbstractGamepadView.tappedWidgets = [] AbstractGamepadView.selectedCmd = "" showWidgetPicker = false } private func clearHighlights(for widgets: [GamepadWidget]) { if widgets.contains(.dPad) || widgets.contains(.up) || widgets.contains(.down) || widgets.contains(.left) || widgets.contains(.right) { dpadHighlight = .none } if widgets.contains(.ls) || widgets.contains(.lsPad) || widgets.contains(.lswheel) || widgets.contains(.lsvPad) { leftStickHighlight = .none } if widgets.contains(.rs) || widgets.contains(.rsPad) || widgets.contains(.rsvPad) || widgets.contains(.rsWheel) { rightStickHighlight = .none } if widgets.contains(.leftTrigger) || widgets.contains(.ltPad) { leftTriggerHighlight = .none } if widgets.contains(.rightTrigger) || widgets.contains(.rtPad) { rightTriggerHighlight = .none } if widgets.contains(.ds4Touchpad) || widgets.contains(.ds4TouchpadButton) { ds4TouchHighlight = .none } } private func toggleDPadSelection(for region: DPadHighlight) { _ = region if dpadWholePressed { dpadHighlight = .none dpadWholePressed = false onCommandDeselected?(GamepadWidget.dPad.cmd) return } handleTappedWidgets([.dPad, .up, .down, .left, .right], sourceIsDPad: true) } private func applyDPadSelection(_ widget: GamepadWidget) { switch widget { case .dPad: dpadHighlight = .none dpadWholePressed = true case .up: dpadWholePressed = false if dpadHighlight.contains(.up) { dpadHighlight.remove(.up) } else { dpadHighlight.insert(.up) } case .down: dpadWholePressed = false if dpadHighlight.contains(.down) { dpadHighlight.remove(.down) } else { dpadHighlight.insert(.down) } case .left: dpadWholePressed = false if dpadHighlight.contains(.left) { dpadHighlight.remove(.left) } else { dpadHighlight.insert(.left) } case .right: dpadWholePressed = false if dpadHighlight.contains(.right) { dpadHighlight.remove(.right) } else { dpadHighlight.insert(.right) } default: break } } private func makeRect(centerX: CGFloat, centerY: CGFloat, width: CGFloat, height: CGFloat) -> CGRect { CGRect( x: centerX - width * 0.5, y: centerY - height * 0.5, width: width, height: height ) } private func triggerRects( containerW: CGFloat, containerH: CGFloat, panelW: CGFloat, w: CGFloat, h: CGFloat ) -> (left: CGRect, right: CGRect) { let triggerWidth = w * layout.topTriggerStubWidthRatio let triggerHeight = h * layout.topTriggerStubHeightRatio let triggerSpacing = max(0, w * (layout.topTriggerStubSpacingRatio - triggerStubSpacingAdjustmentRatio)) let padX = w * layout.topTriggerStubHorizontalPaddingRatio let offsetX = h * layout.topTriggerStubCenterXRatio let offsetY = h * layout.topTriggerStubCenterYRatio let finalOffsetX = offsetX * 2 - containerW * 0.012 let finalOffsetY = offsetY * 2 let stackContentWidth = triggerWidth * 2 + triggerSpacing let stackFrameWidth = stackContentWidth + padX * 2 let panelOriginX = (containerW - panelW) * 0.5 let leftCenterXInStack = panelOriginX + (panelW - stackFrameWidth) * 0.5 + padX + triggerWidth * 0.5 let rightCenterXInStack = leftCenterXInStack + triggerWidth + triggerSpacing let triggerCenterY = containerH * 0.5 + finalOffsetY let leftTriggerCenterX = leftCenterXInStack + finalOffsetX let rightTriggerCenterX = rightCenterXInStack + finalOffsetX return ( makeRect(centerX: leftTriggerCenterX, centerY: triggerCenterY, width: triggerWidth, height: triggerHeight), makeRect(centerX: rightTriggerCenterX, centerY: triggerCenterY, width: triggerWidth, height: triggerHeight) ) } private func handleShoulderTap( localPoint: CGPoint, side: GamepadToggleTarget, shoulderOverlaySize: CGSize, shoulderOffsetX: CGFloat, shoulderOffsetY: CGFloat, containerW: CGFloat, containerH: CGFloat, triggerRect: CGRect ) { let overlayOriginX = (containerW - shoulderOverlaySize.width) * 0.5 + shoulderOffsetX - containerW * 0.012 let overlayOriginY = (containerH - shoulderOverlaySize.height) * 0.5 + shoulderOffsetY let pointInContainer = CGPoint( x: overlayOriginX + localPoint.x, y: overlayOriginY + localPoint.y ) if pointInContainer.x >= triggerRect.minX && pointInContainer.x <= triggerRect.maxX { switch side { case .leftShoulder: handleTappedWidgets([.leftTrigger, .ltPad], sourceIsDPad: false) case .rightShoulder: handleTappedWidgets([.rightTrigger, .rtPad], sourceIsDPad: false) default: toggle(side) } } else { toggle(side) } } private var liveButtons: Set { guard let liveSnapshot else { return [] } var result = Set() if liveSnapshot.pressedButtons.contains(.a) { result.insert(.a) } if liveSnapshot.pressedButtons.contains(.b) { result.insert(.b) } if liveSnapshot.pressedButtons.contains(.x) { result.insert(.x) } if liveSnapshot.pressedButtons.contains(.y) { result.insert(.y) } if liveSnapshot.pressedButtons.contains(.start) { result.insert(.start) } if liveSnapshot.pressedButtons.contains(.select) { result.insert(.select) } if liveSnapshot.pressedButtons.contains(.special) { result.insert(.home) } if liveSnapshot.pressedButtons.contains(.leftShoulder) { result.insert(.leftShoulder) } if liveSnapshot.pressedButtons.contains(.rightShoulder) { result.insert(.rightShoulder) } if liveSnapshot.pressedButtons.contains(.leftStickButton) { result.insert(.leftStick) } if liveSnapshot.pressedButtons.contains(.rightStickButton) { result.insert(.rightStick) } return result } private var effectiveActiveButtons: Set { activeButtons.union(liveButtons) } private var liveDPadHighlight: DPadHighlight { guard let liveSnapshot else { return .none } return DPadHighlight(rawValue: liveSnapshot.dpadHighlight) } private var shouldHideInactiveTriggerStubs: Bool { metricsProfile == .overlay } private var usesOverlayButtonHighlight: Bool { metricsProfile == .overlay } private var effectiveDPadHighlight: DPadHighlight { dpadHighlight.union(liveDPadHighlight) } private var leftStickLiveActive: Bool { guard let liveSnapshot else { return false } return hypot(liveSnapshot.leftStick.x, liveSnapshot.leftStick.y) > stickLiveDeadzone } private var rightStickLiveActive: Bool { guard let liveSnapshot else { return false } return hypot(liveSnapshot.rightStick.x, liveSnapshot.rightStick.y) > stickLiveDeadzone } private var effectiveLeftStickHighlight: StickHighlightMode { if leftStickLiveActive { if liveButtons.contains(.leftStick) || leftStickHighlight == .outerOnly || leftStickHighlight == .both { return .both } return .thumbOnly } if liveButtons.contains(.leftStick) { return .both } return leftStickHighlight } private var effectiveRightStickHighlight: StickHighlightMode { if rightStickLiveActive { if liveButtons.contains(.rightStick) || rightStickHighlight == .outerOnly || rightStickHighlight == .both { return .both } return .thumbOnly } if liveButtons.contains(.rightStick) { return .both } return rightStickHighlight } private func thumbOffset(from point: CGPoint, size: CGFloat) -> CGSize { CGSize(width: size * 0.12 * point.x, height: size * -0.12 * point.y) } var body: some View { GeometryReader { geo in let containerW = geo.size.width let containerH = geo.size.height let contentScale = layout.innerContentScale let clampedPanelHorizontalInsetRatio = max(0, min(0.18, panelHorizontalInsetRatio)) let panelHorizontalInset = containerW * clampedPanelHorizontalInsetRatio let panelWidth = containerW - panelHorizontalInset * 2 let w = containerW * contentScale let h = containerH * contentScale let panelW = panelWidth * contentScale let cornerRadius = h * layout.bodyCornerRadiusRatio // 尺寸 let leftStickSize = w * layout.leftStickSizeRatio let rightStickSize = w * layout.rightStickSizeRatio let dpadSize = w * layout.dpadSizeRatio let abxySize = w * layout.abxySizeRatio let smallTopButtonDiameter = w * layout.smallTopButtonDiameterRatio // 位置约束 let sharedLowerControlCenterY = h * layout.lowerControlCenterYRatio let clampedCenterCompression = max(-0.25, min(0.25, centerCompressionRatio)) let lowerControlHalfSpacing = w * max(0.02, layout.lowerControlHalfSpacingRatio - clampedCenterCompression) let clusterGapAdjustment = w * clusterGapAdjustmentRatio let upperPrimaryClusterVerticalAdjustment = h * upperPrimaryClusterVerticalAdjustmentRatio let leftClusterOffsetX = clusterGapAdjustment let rightClusterOffsetX = -clusterGapAdjustment let dpadCenterX = -lowerControlHalfSpacing + leftClusterOffsetX let rightStickCenterX = lowerControlHalfSpacing + rightClusterOffsetX let sharedTopButtonCenterY = h * layout.topButtonCenterYRatio let minusButtonCenterX = dpadCenterX let plusButtonCenterX = rightStickCenterX let ds4TouchpadCenterX = (dpadCenterX + rightStickCenterX) * 0.5 let ds4TouchpadWidth = w * layout.ds4TouchpadWidthRatio let ds4TouchpadHeight = h * layout.ds4TouchpadHeightRatio let ds4TouchpadCenterY = (-containerH * 0.5) + (ds4TouchpadHeight * 0.5) let leftStickCenterX = w * (layout.leftStickCenterXRatio + clampedCenterCompression) + leftClusterOffsetX let leftStickCenterY = h * layout.leftStickCenterYRatio + upperPrimaryClusterVerticalAdjustment let homeCenterX = w * (layout.homeCenterXRatio - clampedCenterCompression) + rightClusterOffsetX let homeCenterY = h * layout.homeCenterYRatio // ABXY:与 Home 垂直对齐;与左摇杆同一水平轴 let abxyCenterX = homeCenterX let abxyCenterY = leftStickCenterY let shoulderBarWidth = w * layout.shoulderBarWidthRatio let shoulderBarHeight = h * layout.shoulderBarHeightRatio let shoulderBarCenterY = h * layout.shoulderBarCenterYRatio let shoulderHalfX = (panelW * 0.5) - (w * layout.shoulderBarInsetFromSideRatio) - (shoulderBarWidth * 0.5) let leftShoulderX = -shoulderHalfX let rightShoulderX = shoulderHalfX + (w * rightShoulderHorizontalOffsetRatio) let triggerRects = triggerRects( containerW: containerW, containerH: containerH, panelW: panelWidth, w: w, h: h ) let triggerStubWidth = w * layout.topTriggerStubWidthRatio let triggerStubHeight = h * layout.topTriggerStubHeightRatio let triggerStubSpacing = max(0, w * (layout.topTriggerStubSpacingRatio - triggerStubSpacingAdjustmentRatio)) let shoulderHitWidth = shoulderBarWidth * 1.25 let shoulderHitHeight = max(28, shoulderBarHeight * 5.5) ZStack { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(Color.black.opacity(0.12)) .blur(radius: 18) .padding(.horizontal, panelHorizontalInset) .offset(y: h * layout.bodyShadowYOffsetRatio) RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill( LinearGradient( gradient: Gradient(colors: [ Color(red: 0.25, green: 0.86, blue: 0.90), Color(red: 0.17, green: 0.76, blue: 0.82) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .padding(.horizontal, panelHorizontalInset) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.22), lineWidth: 1.2) .padding(.horizontal, panelHorizontalInset) ) RoundedRectangle(cornerRadius: cornerRadius * 0.88, style: .continuous) .stroke(Color.white.opacity(0.10), lineWidth: 2) .padding(.horizontal, panelHorizontalInset) .padding(w * layout.innerStrokeInsetRatio) ZStack { RadialGradient( gradient: Gradient(colors: [ Color.white.opacity(0.14), Color.clear ]), center: .top, startRadius: 10, endRadius: w * layout.radialHighlightEndRadiusRatio ) .clipShape( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ) // 保持你原来的 trigger 布局,不改位置 HStack(spacing: triggerStubSpacing) { TopTriggerStub( width: triggerStubWidth, height: triggerStubHeight, highlightMode: leftTriggerHighlight, liveIntensity: liveSnapshot?.leftTrigger ?? 0, hidesWhenInactive: shouldHideInactiveTriggerStubs, purpleStrength: triggerPurpleStrength ) TopTriggerStub( width: triggerStubWidth, height: triggerStubHeight, highlightMode: rightTriggerHighlight, liveIntensity: liveSnapshot?.rightTrigger ?? 0, hidesWhenInactive: shouldHideInactiveTriggerStubs, purpleStrength: triggerPurpleStrength ) } .frame(width: panelWidth) .padding(.horizontal, w * layout.topTriggerStubHorizontalPaddingRatio) .offset(y: h * layout.topTriggerStubCenterYRatio) .offset(x: h * layout.topTriggerStubCenterXRatio) .padding(.horizontal, w * layout.topTriggerStubHorizontalPaddingRatio) .offset(y: h * layout.topTriggerStubCenterYRatio) .offset(x: h * layout.topTriggerStubCenterXRatio) .zIndex(0) // 肩键:如果触点落在对应 trigger frame,就优先切换 trigger ShoulderButtonBarButton( isHighlighted: effectiveActiveButtons.contains(.leftShoulder), width: shoulderBarWidth, height: shoulderBarHeight, usesOverlayHighlight: usesOverlayButtonHighlight ) { localPoint in handleShoulderTap( localPoint: localPoint, side: .leftShoulder, shoulderOverlaySize: CGSize(width: shoulderHitWidth, height: shoulderHitHeight), shoulderOffsetX: leftShoulderX * 1.005, shoulderOffsetY: shoulderBarCenterY * 0.902, containerW: containerW, containerH: containerH, triggerRect: triggerRects.left ) } .offset(x: leftShoulderX * 1.005, y: shoulderBarCenterY * 0.902) ShoulderButtonBarButton( isHighlighted: effectiveActiveButtons.contains(.rightShoulder), width: shoulderBarWidth, height: shoulderBarHeight, usesOverlayHighlight: usesOverlayButtonHighlight, flipped: true ) { localPoint in handleShoulderTap( localPoint: localPoint, side: .rightShoulder, shoulderOverlaySize: CGSize(width: shoulderHitWidth, height: shoulderHitHeight), shoulderOffsetX: rightShoulderX * 1.065, shoulderOffsetY: shoulderBarCenterY * 0.902, containerW: containerW, containerH: containerH, triggerRect: triggerRects.right ) } .offset(x: rightShoulderX * 1.065, y: shoulderBarCenterY * 0.902) SmallRoundIconButton( symbol: "−", diameter: smallTopButtonDiameter, isHighlighted: effectiveActiveButtons.contains(.select), usesOverlayHighlight: usesOverlayButtonHighlight ) .offset(x: minusButtonCenterX, y: sharedTopButtonCenterY) .onTapGesture { toggle(.select) } SmallRoundIconButton( symbol: "+", diameter: smallTopButtonDiameter, isHighlighted: effectiveActiveButtons.contains(.start), usesOverlayHighlight: usesOverlayButtonHighlight ) .offset(x: plusButtonCenterX, y: sharedTopButtonCenterY) .onTapGesture { toggle(.start) } if gamepadType == .ps && metricsProfile != .overlay { DS4TouchpadView( width: ds4TouchpadWidth, height: ds4TouchpadHeight, highlightMode: ds4TouchHighlight, usesOverlayButtonHighlight: usesOverlayButtonHighlight ) .offset(x: ds4TouchpadCenterX, y: ds4TouchpadCenterY) .onTapGesture { handleTappedWidgets([.ds4Touchpad, .ds4TouchpadButton], sourceIsDPad: false) } } StickView( highlightMode: effectiveLeftStickHighlight, thumbOffset: thumbOffset(from: liveSnapshot?.leftStick ?? .zero, size: leftStickSize), usesOverlayButtonHighlight: usesOverlayButtonHighlight, thumbPurpleStrength: thumbPurpleStrength ) .frame(width: leftStickSize, height: leftStickSize) .offset(x: leftStickCenterX, y: leftStickCenterY) .onTapGesture { handleTappedWidgets([.ls, .lswheel, .lsPad, .lsvPad], sourceIsDPad: false) } DPadXboxStyleView( highlight: effectiveDPadHighlight, wholePressed: dpadWholePressed, usesOverlayButtonHighlight: usesOverlayButtonHighlight ) { tappedRegion in toggleDPadSelection(for: tappedRegion) } .frame(width: dpadSize, height: dpadSize) .offset(x: dpadCenterX, y: sharedLowerControlCenterY) ABXYClusterView( gamepadType: gamepadType, highlightedButtons: effectiveActiveButtons, usesOverlayButtonHighlight: usesOverlayButtonHighlight, onTap: { target in toggle(target) } ) .frame(width: abxySize, height: abxySize) .offset(x: abxyCenterX, y: abxyCenterY) StickView( highlightMode: effectiveRightStickHighlight, thumbOffset: thumbOffset(from: liveSnapshot?.rightStick ?? .zero, size: rightStickSize), usesOverlayButtonHighlight: usesOverlayButtonHighlight, thumbPurpleStrength: thumbPurpleStrength ) .frame(width: rightStickSize, height: rightStickSize) .offset(x: rightStickCenterX, y: sharedLowerControlCenterY) .onTapGesture { handleTappedWidgets([.rs, .rsvPad, .rsPad, .rsWheel], sourceIsDPad: false) } HomeButtonView( diameter: smallTopButtonDiameter, isHighlighted: effectiveActiveButtons.contains(.home), usesOverlayHighlight: usesOverlayButtonHighlight ) .offset(x: homeCenterX, y: homeCenterY) .onTapGesture { toggle(.home) } } .frame(width: containerW, height: containerH) .offset(x: -containerW * 0.012) } .frame(width: containerW, height: containerH) } .aspectRatio(layout.aspectRatio, contentMode: .fit) .overlay( Group { if showWidgetPicker { GamepadWidgetPickerOverlay( widgets: pendingWidgetOptions, onSelect: { widget in selectWidget(widget) }, onReset: { resetCurrentWidgetPickerSelection() }, onCancel: { showWidgetPicker = false } ) .zIndex(999) } } ) .onReceive(Just(resetToken)) { token in guard token != lastAppliedResetToken else { return } lastAppliedResetToken = token resetAllSelections() } .onReceive(Just(selectionSyncToken)) { token in guard token != lastAppliedSelectionSyncToken else { return } lastAppliedSelectionSyncToken = token synchronizeSelectionHighlights() } .onReceive(Just(externalDeselectionToken)) { token in guard token != lastAppliedExternalDeselectionToken else { return } lastAppliedExternalDeselectionToken = token if let command = externalDeselectionCommand, !command.isEmpty { deselectCommand(command) } } .onAppear { synchronizeSelectionHighlights() } } private func resetAllSelections() { activeButtons.removeAll() dpadHighlight = .none dpadWholePressed = false leftStickHighlight = .none rightStickHighlight = .none pendingWidgetOptions.removeAll() showWidgetPicker = false pickerSourceIsDPad = false leftTriggerHighlight = .none rightTriggerHighlight = .none ds4TouchHighlight = .none AbstractGamepadView.tappedWidgets = [] AbstractGamepadView.selectedCmd = "" } private func synchronizeSelectionHighlights() { resetAllSelections() guard let isCommandSelected = isCommandSelected else { return } if isCommandSelected(GamepadWidget.a.cmd) { activeButtons.insert(.a) } if isCommandSelected(GamepadWidget.b.cmd) { activeButtons.insert(.b) } if isCommandSelected(GamepadWidget.x.cmd) { activeButtons.insert(.x) } if isCommandSelected(GamepadWidget.y.cmd) { activeButtons.insert(.y) } if isCommandSelected(GamepadWidget.select.cmd) { activeButtons.insert(.select) } if isCommandSelected(GamepadWidget.start.cmd) { activeButtons.insert(.start) } if isCommandSelected(GamepadWidget.home.cmd) { activeButtons.insert(.home) } if isCommandSelected(GamepadWidget.leftShoulder.cmd) { activeButtons.insert(.leftShoulder) } if isCommandSelected(GamepadWidget.rightShoulder.cmd) { activeButtons.insert(.rightShoulder) } if isCommandSelected(GamepadWidget.dPad.cmd) { dpadWholePressed = true } else { if isCommandSelected(GamepadWidget.up.cmd) { dpadHighlight.insert(.up) } if isCommandSelected(GamepadWidget.down.cmd) { dpadHighlight.insert(.down) } if isCommandSelected(GamepadWidget.left.cmd) { dpadHighlight.insert(.left) } if isCommandSelected(GamepadWidget.right.cmd) { dpadHighlight.insert(.right) } } if isCommandSelected(GamepadWidget.ls.cmd) { leftStickHighlight = .outerOnly } if isCommandSelected(GamepadWidget.lsPad.cmd) || isCommandSelected(GamepadWidget.lswheel.cmd) || isCommandSelected(GamepadWidget.lsvPad.cmd) { leftStickHighlight = leftStickHighlight == .outerOnly ? .both : .thumbOnly } if isCommandSelected(GamepadWidget.rs.cmd) { rightStickHighlight = .outerOnly } if isCommandSelected(GamepadWidget.rsPad.cmd) || isCommandSelected(GamepadWidget.rsvPad.cmd) || isCommandSelected(GamepadWidget.rsWheel.cmd) { rightStickHighlight = rightStickHighlight == .outerOnly ? .both : .thumbOnly } if isCommandSelected(GamepadWidget.leftTrigger.cmd) { leftTriggerHighlight = .button } else if isCommandSelected(GamepadWidget.ltPad.cmd) { leftTriggerHighlight = .pad } if isCommandSelected(GamepadWidget.rightTrigger.cmd) { rightTriggerHighlight = .button } else if isCommandSelected(GamepadWidget.rtPad.cmd) { rightTriggerHighlight = .pad } if isCommandSelected(GamepadWidget.ds4TouchpadButton.cmd) { ds4TouchHighlight = .button } else if isCommandSelected(GamepadWidget.ds4Touchpad.cmd) { ds4TouchHighlight = .pad } } private func deselectCommand(_ command: String) { switch command { case GamepadWidget.a.cmd: activeButtons.remove(.a) case GamepadWidget.b.cmd: activeButtons.remove(.b) case GamepadWidget.x.cmd: activeButtons.remove(.x) case GamepadWidget.y.cmd: activeButtons.remove(.y) case GamepadWidget.select.cmd: activeButtons.remove(.select) case GamepadWidget.start.cmd: activeButtons.remove(.start) case GamepadWidget.home.cmd: activeButtons.remove(.home) case GamepadWidget.leftShoulder.cmd: activeButtons.remove(.leftShoulder) case GamepadWidget.rightShoulder.cmd: activeButtons.remove(.rightShoulder) case GamepadWidget.dPad.cmd: dpadWholePressed = false dpadHighlight = .none case GamepadWidget.up.cmd: dpadHighlight.remove(.up) case GamepadWidget.down.cmd: dpadHighlight.remove(.down) case GamepadWidget.left.cmd: dpadHighlight.remove(.left) case GamepadWidget.right.cmd: dpadHighlight.remove(.right) case GamepadWidget.ls.cmd: if leftStickHighlight == .both { leftStickHighlight = .thumbOnly } else if leftStickHighlight == .outerOnly { leftStickHighlight = .none } case GamepadWidget.lsPad.cmd, GamepadWidget.lswheel.cmd, GamepadWidget.lsvPad.cmd: if leftStickHighlight == .both { leftStickHighlight = .outerOnly } else if leftStickHighlight == .thumbOnly { leftStickHighlight = .none } case GamepadWidget.rs.cmd: if rightStickHighlight == .both { rightStickHighlight = .thumbOnly } else if rightStickHighlight == .outerOnly { rightStickHighlight = .none } case GamepadWidget.rsPad.cmd, GamepadWidget.rsvPad.cmd, GamepadWidget.rsWheel.cmd: if rightStickHighlight == .both { rightStickHighlight = .outerOnly } else if rightStickHighlight == .thumbOnly { rightStickHighlight = .none } case GamepadWidget.leftTrigger.cmd: leftTriggerHighlight = .none case GamepadWidget.ltPad.cmd: leftTriggerHighlight = .none case GamepadWidget.rightTrigger.cmd: rightTriggerHighlight = .none case GamepadWidget.rtPad.cmd: rightTriggerHighlight = .none case GamepadWidget.ds4Touchpad.cmd: ds4TouchHighlight = .none case GamepadWidget.ds4TouchpadButton.cmd: ds4TouchHighlight = .none default: break } if command == GamepadWidget.up.cmd || command == GamepadWidget.down.cmd || command == GamepadWidget.left.cmd || command == GamepadWidget.right.cmd { dpadWholePressed = false } if AbstractGamepadView.selectedCmd == command { AbstractGamepadView.selectedCmd = "" } } } @available(iOS 13.0, *) struct GamepadWidgetPickerOverlay: View { let widgets: [GamepadWidget] let onSelect: (GamepadWidget) -> Void let onReset: () -> Void let onCancel: () -> Void private var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone } var body: some View { let cardSpacing: CGFloat = isPhone ? 8 : 14 let titleFontSize: CGFloat = isPhone ? 15 : 18 let optionSpacing: CGFloat = isPhone ? 6 : 10 let optionHeight: CGFloat = isPhone ? 34 : 44 let optionHorizontalPadding: CGFloat = isPhone ? 9 : 14 let optionTitleFontSize: CGFloat = isPhone ? 12 : 15 let optionCodeFontSize: CGFloat = isPhone ? 10 : 12 let actionButtonHeight: CGFloat = isPhone ? 32 : 42 let actionButtonFontSize: CGFloat = isPhone ? 12 : 15 let cardPadding: CGFloat = isPhone ? 10 : 18 let cardMaxWidth: CGFloat = isPhone ? 250 : 320 let cardCornerRadius: CGFloat = isPhone ? 18 : 22 let horizontalInset: CGFloat = isPhone ? 12 : 24 let topInset: CGFloat = isPhone ? 8 : 0 ZStack { Color.clear .contentShape(Rectangle()) .onTapGesture { onCancel() } VStack(spacing: 0) { VStack(spacing: cardSpacing) { Text(LocalizationHelper.localizedString(forKey: "Select Control")) .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) .foregroundColor(.white) VStack(spacing: optionSpacing) { ForEach(widgets, id: \.self) { widget in Button(action: { onSelect(widget) }) { HStack(spacing: isPhone ? 8 : 12) { Text(widget.description) .font(.system(size: optionTitleFontSize, weight: .semibold, design: .rounded)) .foregroundColor(.white) .lineLimit(1) .minimumScaleFactor(0.6) Spacer() Text(widget.cmd) .font(.system(size: optionCodeFontSize, weight: .bold, design: .rounded)) .foregroundColor(Color.white.opacity(0.72)) .lineLimit(1) } .padding(.horizontal, optionHorizontalPadding) .frame(height: optionHeight) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.white.opacity(0.10)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.16), lineWidth: 1) ) ) } .buttonStyle(PlainButtonStyle()) } } HStack(spacing: isPhone ? 8 : 12) { Button(action: { onCancel() }) { Text(LocalizationHelper.localizedString(forKey: "Cancel")) .font(.system(size: actionButtonFontSize, weight: .bold, design: .rounded)) .foregroundColor(Color.white.opacity(0.9)) .frame(maxWidth: .infinity) .frame(height: actionButtonHeight) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.black.opacity(0.16)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.12), lineWidth: 1) ) ) } .buttonStyle(PlainButtonStyle()) Button(action: { onReset() }) { Text(LocalizationHelper.localizedString(forKey: "Reset")) .font(.system(size: actionButtonFontSize, weight: .bold, design: .rounded)) .foregroundColor(Color.white.opacity(0.9)) .frame(maxWidth: .infinity) .frame(height: actionButtonHeight) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.black.opacity(0.16)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.12), lineWidth: 1) ) ) } .buttonStyle(PlainButtonStyle()) } .buttonStyle(PlainButtonStyle()) } .padding(cardPadding) .frame(maxWidth: cardMaxWidth) .background( RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous) .fill( LinearGradient( gradient: Gradient(colors: [ Color(red: 0.15, green: 0.46, blue: 0.50), Color(red: 0.11, green: 0.34, blue: 0.39) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous) .stroke(Color.white.opacity(0.18), lineWidth: 1.2) ) ) .shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 10) .padding(.top, topInset) .padding(.horizontal, horizontalInset) Spacer(minLength: 0) } } } } // MARK: - 顶部小突起 @available(iOS 13.0, *) struct TopTriggerStub: View { var width: CGFloat var height: CGFloat var highlightMode: TriggerHighlightMode = .none var liveIntensity: CGFloat = 0 var hidesWhenInactive: Bool = false var purpleStrength: CGFloat = 1 private var isHighlighted: Bool { highlightMode != .none || liveIntensity > 0.001 } private var clampedLiveIntensity: Double { Double(max(0, min(1, liveIntensity))) } private var normalizedPurpleStrength: Double { Double(max(0, min(2, purpleStrength))) } private func mix(_ lhs: Double, _ rhs: Double, _ t: Double) -> Double { lhs + (rhs - lhs) * t } private func triggerPurpleColor(light: (r: Double, g: Double, b: Double, a: Double), base: (r: Double, g: Double, b: Double, a: Double), deep: (r: Double, g: Double, b: Double, a: Double)) -> Color { if normalizedPurpleStrength <= 1 { let t = normalizedPurpleStrength return Color( red: mix(light.r, base.r, t), green: mix(light.g, base.g, t), blue: mix(light.b, base.b, t), opacity: mix(light.a, base.a, t) ) } else { let t = normalizedPurpleStrength - 1 return Color( red: mix(base.r, deep.r, t), green: mix(base.g, deep.g, t), blue: mix(base.b, deep.b, t), opacity: mix(base.a, deep.a, t) ) } } private var gradientColors: [Color] { if highlightMode == .pad { return [GamepadPadHighlightStyle.fillTop, GamepadPadHighlightStyle.fillBottom] } if highlightMode == .button { return [GamepadButtonHighlightStyle.fillTop, GamepadButtonHighlightStyle.fillBottom] } return [Color.white.opacity(0.97), Color(red: 0.86, green: 0.86, blue: 0.88)] } private var liveOverlayOpacity: Double { guard clampedLiveIntensity > 0 else { return 0 } return 0.16 + clampedLiveIntensity * 0.84 } private var strokeColor: Color { if clampedLiveIntensity > 0 || highlightMode == .pad { return triggerPurpleColor( light: (0.56, 0.60, 0.92, 0.58), base: (0.38, 0.42, 0.82, 0.78), deep: (0.27, 0.30, 0.68, 0.96) ) } if highlightMode == .button { return GamepadButtonHighlightStyle.stroke } return Color.black.opacity(0.08) } private var inactiveOpacity: Double { hidesWhenInactive && !isHighlighted ? 0 : 1 } var body: some View { RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) .fill( LinearGradient( gradient: Gradient(colors: gradientColors), startPoint: .top, endPoint: .bottom ) ) .frame(width: width, height: height) .overlay( RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) .fill( LinearGradient( gradient: Gradient(colors: [ triggerPurpleColor( light: (0.88, 0.89, 1.00, 0.48), base: (0.82, 0.84, 1.00, 0.92), deep: (0.66, 0.70, 0.98, 0.98) ), triggerPurpleColor( light: (0.73, 0.76, 0.98, 0.42), base: (0.56, 0.60, 0.94, 0.84), deep: (0.38, 0.42, 0.82, 0.96) ) ]), startPoint: .top, endPoint: .bottom ) ) .opacity(liveOverlayOpacity) ) .overlay( RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) .stroke(strokeColor, lineWidth: isHighlighted ? 1.4 : 1) ) .overlay( LinearGradient( gradient: Gradient(colors: [ Color.white.opacity(isHighlighted ? 0.18 : 0.28), Color.clear ]), startPoint: .top, endPoint: .bottom ) .clipShape( RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) ) ) .shadow(color: Color.black.opacity(0.10), radius: 2, x: 0, y: 1) .opacity(inactiveOpacity) } } // MARK: - 顶部 +/- 小按钮 @available(iOS 13.0, *) struct SmallRoundIconButton: View { var symbol: String var diameter: CGFloat = 35 var isHighlighted: Bool = false var usesOverlayHighlight: Bool = false private var highlightedColors: [Color] { usesOverlayHighlight ? [GamepadOverlayButtonHighlightStyle.fillTop, GamepadOverlayButtonHighlightStyle.fillBottom] : [Color(red: 1.00, green: 0.92, blue: 0.50), Color(red: 0.95, green: 0.73, blue: 0.22)] } private var highlightedStroke: Color { usesOverlayHighlight ? GamepadOverlayButtonHighlightStyle.stroke : Color.orange.opacity(0.7) } var body: some View { Circle() .fill( LinearGradient( gradient: Gradient(colors: isHighlighted ? highlightedColors : [ Color(red: 0.23, green: 0.82, blue: 0.86), Color(red: 0.16, green: 0.68, blue: 0.74) ]), startPoint: .top, endPoint: .bottom ) ) .overlay( Circle() .stroke( isHighlighted ? highlightedStroke : Color.black.opacity(0.16), lineWidth: isHighlighted ? 1.5 : 1 ) ) .overlay( Circle() .stroke(Color.white.opacity(isHighlighted ? 0.28 : 0.18), lineWidth: 1) .padding(2) ) .overlay( Text(symbol) .font(.system(size: diameter * 0.65, weight: .bold, design: .rounded)) .foregroundColor(isHighlighted ? Color.white.opacity(0.95) : Color.white.opacity(0.65)) .offset(x: diameter * 0.013) .offset(y: diameter * -0.042) ) .frame(width: diameter, height: diameter) .shadow(color: Color.black.opacity(0), radius: 3, x: 0, y: 2) } } @available(iOS 13.0, *) struct DS4TouchpadView: View { private enum Ratio { static let corner: CGFloat = 0.30 static let insetStroke: CGFloat = 0.06 static let dotInsetX: CGFloat = 0.07 static let dotInsetY: CGFloat = 0.10 static let dotColumns: Int = 20 static let dotRows: Int = 8 static let dotSizeFactor: CGFloat = 0.28 } var width: CGFloat var height: CGFloat var highlightMode: DS4TouchHighlightMode = .none var usesOverlayButtonHighlight: Bool = false private var isHighlighted: Bool { highlightMode != .none } private var fillTop: Color { highlightMode == .pad ? GamepadPadHighlightStyle.fillTop : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop) } private var fillBottom: Color { highlightMode == .pad ? GamepadPadHighlightStyle.fillBottom : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom) } private var strokeColor: Color { highlightMode == .pad ? GamepadPadHighlightStyle.stroke : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke) } private var dotColor: Color { if isHighlighted { return Color.white.opacity(0.28) } return Color.black.opacity(0.18) } private var recessedShadowOpacity: Double { isHighlighted ? 0.0 : 0.28 } private var recessedShadowColor: Color { Color(red: 0.16, green: 0.45, blue: 0.54).opacity(recessedShadowOpacity * 0.9) } private var recessedHighlightColor: Color { Color.white.opacity(isHighlighted ? 0.0 : 0.10) } var body: some View { let cornerRadius = height * Ratio.corner let shape = DS4TouchpadShape(bottomCornerRadius: cornerRadius) shape .fill( LinearGradient( gradient: Gradient(colors: isHighlighted ? [ fillTop, fillBottom ] : [ Color.clear, Color.clear ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: width, height: height) .contentShape(shape) .overlay( shape .stroke( isHighlighted ? strokeColor : Color.black.opacity(0.16), lineWidth: isHighlighted ? 1.6 : 1 ) ) .overlay( DS4TouchpadShape(bottomCornerRadius: cornerRadius * 0.88) .stroke(Color.white.opacity(isHighlighted ? 0.18 : 0.08), lineWidth: 1) .padding(height * Ratio.insetStroke) ) .overlay( dotPattern .padding(.horizontal, width * Ratio.dotInsetX) .padding(.vertical, height * Ratio.dotInsetY) .clipShape( DS4TouchpadShape(bottomCornerRadius: cornerRadius * 0.86) ) ) .overlay( shape .stroke(recessedShadowColor, lineWidth: 1.2) .offset(y: 0.8) .opacity(isHighlighted ? 0.0 : 0.9) ) .overlay( shape .stroke(recessedHighlightColor, lineWidth: 0.8) .offset(y: -0.4) .opacity(isHighlighted ? 0.0 : 1.0) ) .shadow(color: Color(red: 0.14, green: 0.36, blue: 0.42).opacity(isHighlighted ? 0.10 : 0.010), radius: isHighlighted ? 3 : 0.6, x: 0, y: isHighlighted ? 2 : 0.2) } private var dotPattern: some View { GeometryReader { geo in DS4TouchpadDotGridShape( columns: Ratio.dotColumns, rows: Ratio.dotRows, dotSizeFactor: Ratio.dotSizeFactor ) .fill(dotColor) } } } @available(iOS 13.0, *) struct DS4TouchpadDotGridShape: Shape { let columns: Int let rows: Int let dotSizeFactor: CGFloat func path(in rect: CGRect) -> Path { guard columns > 0, rows > 0, rect.width > 0, rect.height > 0 else { return Path() } let cellWidth = rect.width / CGFloat(columns) let cellHeight = rect.height / CGFloat(rows) let dotDiameter = min(cellWidth, cellHeight) * dotSizeFactor let radius = dotDiameter * 0.5 var path = Path() for row in 0.. Path { let radius = min(bottomCornerRadius, rect.width * 0.5, rect.height) var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius)) path.addArc( center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), radius: radius, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false ) path.addLine(to: CGPoint(x: rect.minX + radius, y: rect.maxY)) path.addArc( center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), radius: radius, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false ) path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) path.closeSubpath() return path } } // MARK: - 摇杆 @available(iOS 13.0, *) private enum GamepadPadHighlightStyle { static let fillTop = Color(red: 0.82, green: 0.84, blue: 1.00).opacity(0.92) static let fillBottom = Color(red: 0.56, green: 0.60, blue: 0.94).opacity(0.84) static let stroke = Color(red: 0.38, green: 0.42, blue: 0.82).opacity(0.90) } @available(iOS 13.0, *) private enum GamepadButtonHighlightStyle { static let fillTop = Color(red: 1.00, green: 0.96, blue: 0.72) static let fillBottom = Color(red: 0.95, green: 0.78, blue: 0.34) static let stroke = Color.orange.opacity(0.75) } @available(iOS 13.0, *) private enum GamepadOverlayButtonHighlightStyle { static let fillTop = Color(red: 1.00, green: 0.94, blue: 0.68) static let fillBottom = Color(red: 0.95, green: 0.76, blue: 0.26) static let stroke = Color(red: 0.86, green: 0.52, blue: 0.08).opacity(0.88) } @available(iOS 13.0, *) private enum GamepadOverlayShoulderHighlightStyle { static let fillTop = Color(red: 1.00, green: 0.85, blue: 0.53) static let fillBottom = Color(red: 0.94, green: 0.625, blue: 0.176) } @available(iOS 13.0, *) private enum GamepadStickThumbStyle { static let neutralFillTop = Color(red: 0.95, green: 0.95, blue: 0.96) static let neutralFillBottom = Color(red: 0.86, green: 0.86, blue: 0.88) static let activeFillTop = GamepadPadHighlightStyle.fillTop.opacity(0.92) static let activeFillBottom = GamepadPadHighlightStyle.fillBottom.opacity(0.88) static let ring = Color.black.opacity(0.125) static let activeRing = GamepadPadHighlightStyle.stroke static let ringWidth: CGFloat = 1.35 } @available(iOS 13.0, *) struct StickView: View { private struct Ratio { static let base: CGFloat = 0.66 static let shell: CGFloat = 0.60 static let thumb: CGFloat = 0.45 static let shadowOffsetX: CGFloat = 0 static let shadowOffsetY: CGFloat = 0 } var highlightMode: StickHighlightMode = .none var thumbOffset: CGSize = .zero var usePadHighlightForOuter: Bool = false var usesOverlayButtonHighlight: Bool = false var thumbPurpleStrength: CGFloat = 1 private var outerHighlighted: Bool { highlightMode == .outerOnly || highlightMode == .both } private var thumbHighlighted: Bool { highlightMode == .thumbOnly || highlightMode == .both } private var normalizedThumbPurpleStrength: Double { Double(max(0, min(2, thumbPurpleStrength))) } private func mix(_ lhs: Double, _ rhs: Double, _ t: Double) -> Double { lhs + (rhs - lhs) * t } private func thumbPurpleColor(light: (r: Double, g: Double, b: Double, a: Double), base: (r: Double, g: Double, b: Double, a: Double), deep: (r: Double, g: Double, b: Double, a: Double)) -> Color { if normalizedThumbPurpleStrength <= 1 { let t = normalizedThumbPurpleStrength return Color( red: mix(light.r, base.r, t), green: mix(light.g, base.g, t), blue: mix(light.b, base.b, t), opacity: mix(light.a, base.a, t) ) } else { let t = normalizedThumbPurpleStrength - 1 return Color( red: mix(base.r, deep.r, t), green: mix(base.g, deep.g, t), blue: mix(base.b, deep.b, t), opacity: mix(base.a, deep.a, t) ) } } private var thumbActiveFillTop: Color { thumbPurpleColor( light: (0.90, 0.91, 1.00, 0.55), base: (0.82, 0.84, 1.00, 0.92), deep: (0.66, 0.70, 0.98, 1.00) ) } private var thumbActiveFillBottom: Color { thumbPurpleColor( light: (0.74, 0.78, 0.99, 0.48), base: (0.56, 0.60, 0.94, 0.84), deep: (0.37, 0.41, 0.80, 0.98) ) } private var thumbActiveStroke: Color { thumbPurpleColor( light: (0.58, 0.62, 0.92, 0.58), base: (0.38, 0.42, 0.82, 0.90), deep: (0.26, 0.30, 0.67, 1.00) ) } var body: some View { GeometryReader { geo in let s = min(geo.size.width, geo.size.height) ZStack { Circle() .fill( LinearGradient( gradient: Gradient(colors: outerHighlighted ? [ usePadHighlightForOuter ? GamepadPadHighlightStyle.fillTop : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop), usePadHighlightForOuter ? GamepadPadHighlightStyle.fillBottom : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom) ] : [ Color.white.opacity(0.98), Color(red: 0.88, green: 0.88, blue: 0.90) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: s * Ratio.shell, height: s * Ratio.shell) .overlay( Circle() .stroke( outerHighlighted ? (usePadHighlightForOuter ? GamepadPadHighlightStyle.stroke : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke)) : Color.black.opacity(0.10), lineWidth: outerHighlighted ? 1.5 : 1 ) ) ZStack { Circle() .fill( LinearGradient( gradient: Gradient(colors: thumbHighlighted ? [ thumbActiveFillTop, thumbActiveFillBottom ] : [ GamepadStickThumbStyle.neutralFillTop, GamepadStickThumbStyle.neutralFillBottom ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) Circle() .stroke( thumbHighlighted ? thumbActiveStroke : GamepadStickThumbStyle.ring, lineWidth: GamepadStickThumbStyle.ringWidth ) } .frame(width: s * Ratio.thumb, height: s * Ratio.thumb) .offset(thumbOffset) } .frame(width: geo.size.width, height: geo.size.height) } } }// MARK: - 十字键 @available(iOS 13.0, *) struct DPadXboxStyleView: View { var highlight: DPadHighlight = .none var wholePressed: Bool = false var usesOverlayButtonHighlight: Bool = false var onTapRegion: ((DPadHighlight) -> Void)? = nil var body: some View { GeometryReader { geo in let s = min(geo.size.width, geo.size.height) let arm = s * 0.78 let thickness = s * 0.30 ZStack { DPadCrossShape() .fill(Color.black.opacity(0.25)) .frame(width: arm, height: arm) .offset(x: -2, y: 3) .blur(radius: 1) DPadCrossShape() .fill(Color.black.opacity(0.90)) .frame(width: arm, height: arm) DPadCrossBaseView( arm: arm, highlight: highlight, wholePressed: wholePressed, usesOverlayButtonHighlight: usesOverlayButtonHighlight ) Group { dpadArrow("▴", direction: .up).offset(y: -thickness * 0.95) dpadArrow("▾", direction: .down).offset(y: thickness * 0.95) dpadArrow("◂", direction: .left).offset(x: -thickness * 0.95) dpadArrow("▸", direction: .right).offset(x: thickness * 0.95) } .font(.system(size: s * 0.14, weight: .bold)) } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onEnded { value in let region = resolveDPadRegion( point: value.location, size: geo.size ) onTapRegion?(region) } ) .frame(width: geo.size.width, height: geo.size.height) } } private func resolveDPadRegion(point: CGPoint, size: CGSize) -> DPadHighlight { let s = min(size.width, size.height) let center = CGPoint(x: size.width / 2, y: size.height / 2) let dx = point.x - center.x let dy = point.y - center.y let centerHalf: CGFloat = s * 0.15 if abs(dx) <= centerHalf && abs(dy) <= centerHalf { return .all } if abs(dx) > abs(dy) { return dx < 0 ? .left : .right } else { return dy < 0 ? .up : .down } } private func dpadArrow(_ symbol: String, direction: DPadHighlight) -> some View { let isOn = wholePressed || highlight.contains(direction) return Text(symbol) .foregroundColor(isOn ? Color.black.opacity(0.22) : Color.black.opacity(0.12)) } } @available(iOS 13.0.0, *) struct DPadCrossBaseView: View { let arm: CGFloat let highlight: DPadHighlight let wholePressed: Bool let usesOverlayButtonHighlight: Bool var body: some View { ZStack { dpadSegment(.up) dpadSegment(.down) dpadSegment(.left) dpadSegment(.right) centerSquare } .frame(width: arm - 2, height: arm - 1) } @ViewBuilder private func dpadSegment(_ dir: DPadHighlight) -> some View { let isOn = wholePressed || highlight.contains(dir) let usePadHighlight = wholePressed DPadSegmentShape(direction: dir) .fill( LinearGradient( gradient: Gradient(colors: isOn ? [ usePadHighlight ? GamepadPadHighlightStyle.fillTop : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop), usePadHighlight ? GamepadPadHighlightStyle.fillBottom : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom) ] : [ Color.white.opacity(0.98), Color(red: 0.88, green: 0.88, blue: 0.90) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( DPadSegmentShape(direction: dir) .stroke( isOn ? (usePadHighlight ? GamepadPadHighlightStyle.stroke : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke)) : Color.clear, lineWidth: 1 ) ) } private var centerSquare: some View { let isOn = wholePressed return RoundedRectangle(cornerRadius: arm * 0.04, style: .continuous) .fill( LinearGradient( gradient: Gradient(colors: isOn ? [ GamepadPadHighlightStyle.fillTop, GamepadPadHighlightStyle.fillBottom ] : [ Color.white.opacity(0.98), Color(red: 0.87, green: 0.87, blue: 0.89) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: arm * 0.33, height: arm * 0.33) .overlay( RoundedRectangle(cornerRadius: arm * 0.04, style: .continuous) .stroke(isOn ? GamepadPadHighlightStyle.stroke : Color.black.opacity(0.05), lineWidth: 1) ) } } @available(iOS 13.0.0, *) struct DPadCrossShape: Shape { func path(in rect: CGRect) -> Path { let w = rect.width let h = rect.height let half = w * 0.5 let thickness = w * 0.33 let tHalf = thickness * 0.5 let radius = w * 0.06 func rr(_ x: CGFloat, _ y: CGFloat, _ width: CGFloat, _ height: CGFloat) -> Path { Path( roundedRect: CGRect(x: x, y: y, width: width, height: height), cornerRadius: radius ) } var p = Path() p.addPath(rr(half - tHalf, 0, thickness, h)) p.addPath(rr(0, half - tHalf, w, thickness)) return p } } @available(iOS 13.0.0, *) struct DPadSegmentShape: Shape { let direction: DPadHighlight func path(in rect: CGRect) -> Path { let w = rect.width let h = rect.height let thickness = w * 0.33 let halfT = thickness / 2 let cx = w / 2 let cy = h / 2 let r = w * 0.06 var p = Path() switch direction { case .up: p.addRoundedRect( in: CGRect(x: cx - halfT, y: 0, width: thickness, height: cy), cornerSize: CGSize(width: r, height: r) ) case .down: p.addRoundedRect( in: CGRect(x: cx - halfT, y: cy, width: thickness, height: cy), cornerSize: CGSize(width: r, height: r) ) case .left: p.addRoundedRect( in: CGRect(x: 0, y: cy - halfT, width: cx, height: thickness), cornerSize: CGSize(width: r, height: r) ) case .right: p.addRoundedRect( in: CGRect(x: cx, y: cy - halfT, width: cx, height: thickness), cornerSize: CGSize(width: r, height: r) ) default: break } return p } } // MARK: - ABXY @available(iOS 13.0, *) struct ABXYClusterView: View { let gamepadType: GamepadType let highlightedButtons: Set let usesOverlayButtonHighlight: Bool let onTap: (GamepadToggleTarget) -> Void var body: some View { GeometryReader { geo in let s = min(geo.size.width, geo.size.height) let offset = s * 0.3 let size = s * 0.31 ZStack { LetterButton(title: leftButtonTitle, systemSymbolName: leftButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.x), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: -offset, y: 0) .onTapGesture { onTap(.x) } LetterButton(title: topButtonTitle, systemSymbolName: topButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.y), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: 0, y: -offset) .onTapGesture { onTap(.y) } LetterButton(title: rightButtonTitle, systemSymbolName: rightButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.b), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: offset, y: 0) .onTapGesture { onTap(.b) } LetterButton(title: bottomButtonTitle, systemSymbolName: bottomButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.a), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: 0, y: offset) .onTapGesture { onTap(.a) } } .frame(width: geo.size.width, height: geo.size.height) } } private var leftButtonTitle: String { gamepadType == .ps ? "" : "X" } private var topButtonTitle: String { gamepadType == .ps ? "" : "Y" } private var rightButtonTitle: String { gamepadType == .ps ? "" : "B" } private var bottomButtonTitle: String { gamepadType == .ps ? "" : "A" } private var leftButtonSymbolName: String? { gamepadType == .ps ? "square" : nil } private var topButtonSymbolName: String? { gamepadType == .ps ? "triangle" : nil } private var rightButtonSymbolName: String? { gamepadType == .ps ? "circle" : nil } private var bottomButtonSymbolName: String? { gamepadType == .ps ? "xmark" : nil } } @available(iOS 13.0, *) struct LetterButton: View { var title: String var systemSymbolName: String? = nil var size: CGFloat var isHighlighted: Bool = false var usesOverlayHighlight: Bool = false private var highlightedColors: [Color] { usesOverlayHighlight ? [GamepadOverlayButtonHighlightStyle.fillTop, GamepadOverlayButtonHighlightStyle.fillBottom] : [Color(red: 1.00, green: 0.96, blue: 0.74), Color(red: 0.95, green: 0.78, blue: 0.32)] } private var highlightedStroke: Color { usesOverlayHighlight ? GamepadOverlayButtonHighlightStyle.stroke : Color.orange.opacity(0.75) } var body: some View { ZStack { Circle() .fill(Color.black.opacity(0.20)) .offset(x: -1, y: 3) .blur(radius: 1) Circle() .fill( LinearGradient( gradient: Gradient(colors: isHighlighted ? highlightedColors : [ Color.white.opacity(0.98), Color(red: 0.88, green: 0.88, blue: 0.90) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( Circle() .stroke( isHighlighted ? highlightedStroke : Color.black.opacity(0.12), lineWidth: isHighlighted ? 1.5 : 1 ) ) if let systemSymbolName { Image(systemName: systemSymbolName) .font(.system(size: size * 0.42, weight: .semibold, design: .rounded)) .foregroundColor(isHighlighted ? Color.black.opacity(0.58) : Color.black.opacity(0.28)) } else { Text(title) .font(.system(size: size * 0.53, weight: .medium, design: .rounded)) .foregroundColor(isHighlighted ? Color.black.opacity(0.58) : Color.black.opacity(0.28)) } } } } // MARK: - Home Button @available(iOS 13.0, *) struct HomeButtonView: View { var diameter: CGFloat var isHighlighted: Bool = false var usesOverlayHighlight: Bool = false private var innerInset: CGFloat { diameter * 0.06 } private var shadowOffsetY: CGFloat { diameter * 0.05 } private var highlightedColors: [Color] { usesOverlayHighlight ? [GamepadOverlayButtonHighlightStyle.fillTop, GamepadOverlayButtonHighlightStyle.fillBottom] : [Color(red: 1.00, green: 0.92, blue: 0.52), Color(red: 0.96, green: 0.74, blue: 0.24)] } private var highlightedStroke: Color { usesOverlayHighlight ? GamepadOverlayButtonHighlightStyle.stroke : Color.orange.opacity(0.75) } var body: some View { ZStack { Circle() .fill(Color.black.opacity(0.18)) .offset(y: shadowOffsetY) .blur(radius: 1) Circle() .fill( LinearGradient( gradient: Gradient(colors: isHighlighted ? highlightedColors : [ Color(red: 0.25, green: 0.86, blue: 0.90), Color(red: 0.18, green: 0.72, blue: 0.78) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( Circle() .stroke( isHighlighted ? highlightedStroke : Color.black.opacity(0.16), lineWidth: isHighlighted ? 1.5 : 1 ) ) .overlay( Circle() .stroke(Color.white.opacity(isHighlighted ? 0.28 : 0.22), lineWidth: 1) .padding(innerInset) ) Image(systemName: "house") .font(.system(size: diameter * 0.45, weight: .bold)) .foregroundColor(.white.opacity(isHighlighted ? 1.0 : 0.9)) } .frame(width: diameter, height: diameter) } } // MARK: - 肩键 @available(iOS 13.0, *) struct ShoulderButtonBarButton: View { var isHighlighted: Bool var width: CGFloat var height: CGFloat var usesOverlayHighlight: Bool = false var flipped: Bool = false var action: (CGPoint) -> Void var body: some View { ZStack { ShoulderButtonBar(isHighlighted: isHighlighted, usesOverlayHighlight: usesOverlayHighlight) .scaleEffect(x: flipped ? -1 : 1, y: 1) .frame(width: width, height: height) Rectangle() .fill(Color.white.opacity(0.001)) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onEnded { value in action(value.location) } ) } .frame(width: width * 1.25, height: max(28, height * 5.5)) } } @available(iOS 13.0, *) struct ShoulderButtonBar: View { var isHighlighted: Bool = false var usesOverlayHighlight: Bool = false var body: some View { GeometryReader { geo in let h = geo.size.height let lineWidth = h * 1.16 let shape = ShoulderCenterLineShape( startY: 4.5, controlX: 0.27, controlY: 0.35, endY: 0.45, horizontalTail: 0 ) ZStack { shape .stroke( Color.black.opacity(0.10), style: StrokeStyle( lineWidth: lineWidth, lineCap: .round, lineJoin: .round ) ) .offset(y: h * 0.07) .blur(radius: 1) shape .stroke( LinearGradient( gradient: Gradient(colors: isHighlighted ? [ usesOverlayHighlight ? GamepadOverlayShoulderHighlightStyle.fillTop : Color(red: 1.00, green: 0.95, blue: 0.65), usesOverlayHighlight ? GamepadOverlayShoulderHighlightStyle.fillBottom : Color(red: 0.95, green: 0.77, blue: 0.28) ] : [ .white.opacity(0.22), Color(red: 0.86, green: 0.86, blue: 0.88) ]), startPoint: .top, endPoint: .bottom ), style: StrokeStyle( lineWidth: lineWidth, lineCap: .round, lineJoin: .round ) ) shape .stroke( LinearGradient( gradient: Gradient(colors: [ isHighlighted ? .white.opacity(0.34) : .white.opacity(0.56) ]), startPoint: .top, endPoint: .bottom ), style: StrokeStyle( lineWidth: lineWidth * 0.675, lineCap: .round, lineJoin: .round ) ) .offset(y: -h * 0.05) } } } } @available(iOS 13.0, *) struct ShoulderCenterLineShape: Shape { let startY: CGFloat let controlX: CGFloat let controlY: CGFloat let endY: CGFloat let horizontalTail: CGFloat func path(in rect: CGRect) -> Path { let w = rect.width let h = rect.height let left = w * 0.08 let right = w * 0.94 let start = CGPoint(x: left, y: h * startY) let control = CGPoint(x: w * controlX, y: h * controlY) let flattenStartX = right - w * 0.06 let flattenStart = CGPoint(x: flattenStartX, y: h * endY) let end = CGPoint(x: right, y: h * endY) let tailEnd = CGPoint(x: right + w * horizontalTail, y: h * endY) var p = Path() p.move(to: start) p.addQuadCurve( to: flattenStart, control: control ) p.addQuadCurve( to: end, control: CGPoint(x: right - w * 0.02, y: h * endY) ) p.addLine(to: tailEnd) return p } } // MARK: - Preview @available(iOS 13.0, *) struct XboxAbstractPadView_Previews: PreviewProvider { static var previews: some View { ZStack { Color(red: 0.93, green: 0.93, blue: 0.94) .edgesIgnoringSafeArea(.all) AbstractGamepadView(gamepadType: .ps) .frame(width: 500, height: 330) .padding(20) } .previewLayout(.sizeThatFits) } }