Files
VoidLink/VoidLinkPreview/AbstractGamepadView.swift
T
2026-05-21 14:18:50 +08:00

2547 lines
98 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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<GamepadToggleTarget> = []
@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<GamepadToggleTarget> {
guard let liveSnapshot else { return [] }
var result = Set<GamepadToggleTarget>()
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(.menu) { result.insert(.start) }
if liveSnapshot.pressedButtons.contains(.back) { 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<GamepadToggleTarget> {
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..<rows {
for column in 0..<columns {
let center = CGPoint(
x: cellWidth * (CGFloat(column) + 0.5),
y: cellHeight * (CGFloat(row) + 0.5)
)
let dotRect = CGRect(
x: center.x - radius,
y: center.y - radius,
width: dotDiameter,
height: dotDiameter
)
path.addEllipse(in: dotRect)
}
}
return path
}
}
@available(iOS 13.0, *)
struct DS4TouchpadShape: Shape {
let bottomCornerRadius: CGFloat
func path(in rect: CGRect) -> 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<GamepadToggleTarget>
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)
}
}