-
-
Notifications
You must be signed in to change notification settings - Fork 704
[Agent] feat(default-skin): global controller overlay scale & opacity settings #3523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
33393d7
4eb7576
43c8a93
a5ab465
b6f8a6a
92c35ca
fd6cd71
5a36b84
02e5ef1
a376bb5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| ### Added | ||
| - **Controller Overlay Scale** — New global setting (0.5×–2.0×, default 1.0×) to resize the on-screen controller overlay; accessible via Settings → On-Screen Controller | ||
|
|
||
| ### Changed | ||
| - **Portrait controller scaling** — Portrait mode applies `scaleEffect` to visually resize controls while clipping overflow and constraining hit-testing to the allocated frame area, so buttons genuinely shrink or grow at every scale value | ||
| - **Landscape edge-anchored scaling** — In landscape, the left panel scales from the screen's leading edge and the right panel from the trailing edge, so buttons always grow *inward* toward the center instead of off-screen | ||
| - **Viewport calculation tracks scale** — Both portrait (`controllerHeight`) and landscape (`sideReserve`) viewport calculations now read `controllerScale` so the game render area exactly matches the space freed/taken by the overlay at every scale setting | ||
| - **Live viewport update on scale change** — Changing the scale slider while in-game now immediately re-emits the game viewport so the render area adjusts without requiring rotation or relaunch | ||
| - **Portrait overlay opacity covers background** — `controllerOpacity` is now applied to the RetrowaveBackground panel as well as the controls, so the entire overlay fades uniformly | ||
| - **Controller fraction clamp range updated to 20–70%** — The portrait controller-area height was changed from a fixed 35% allocation to a clamped 20–70% range (capped at 70%), so a 2× scale setting can be represented without the layout and visual scale disagreeing |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| import PVLogging | ||
| import PVUIBase | ||
| import PVSettings | ||
| import Defaults | ||
|
|
||
| // MARK: - Retrowave Styling Components | ||
|
|
||
|
|
@@ -151,7 +152,7 @@ | |
| } | ||
|
|
||
| // Separate view to handle the default controller skin with its own state | ||
| struct DefaultControllerSkinView: View { | ||
|
Check failure on line 155 in PVUI/Sources/PVUIBase/SwiftUI/DeltaSkins/Views/Display/EmulatorWithSkinView+DefaultSkin.swift
|
||
| // Initial value from parent | ||
| @State private var useJoystickInternal: Bool | ||
| let inputHandler: DeltaSkinInputHandler | ||
|
|
@@ -179,6 +180,10 @@ | |
| // Bridge to protocol system (replaces notification system) | ||
| @State private var viewportBridge: ViewportLayoutProviderBridge? | ||
|
|
||
| // User-configurable overlay scale and opacity | ||
| @Default(.controllerScale) private var controllerScale | ||
| @Default(.controllerOpacity) private var controllerOpacity | ||
|
|
||
| init(useJoystick: Bool, inputHandler: DeltaSkinInputHandler, systemId: SystemIdentifier?, coreInstance: PVEmulatorCore) { | ||
| self._useJoystickInternal = State(initialValue: useJoystick) | ||
| self.inputHandler = inputHandler | ||
|
|
@@ -208,12 +213,27 @@ | |
| } | ||
| } | ||
|
|
||
| /// Returns the fraction of the view height allocated to the controller overlay in portrait mode. | ||
| /// Extracted to ensure the SwiftUI layout and the viewport calculation always use the same value. | ||
| /// The incoming `scale` value is expected to be in the range 0.5–2.0, but since it comes from | ||
| /// persisted Defaults, we defensively sanitize and clamp it to avoid NaN / non-finite layouts. | ||
| private static func portraitControllerFraction(scale: Double) -> CGFloat { | ||
| // Normalize non-finite values (NaN / ±infinity) to a safe default of 1.0 | ||
| let finiteScale = scale.isFinite ? scale : 1.0 | ||
| // Clamp to the expected range from settings UI | ||
| let clampedScale = min(2.0, max(0.5, finiteScale)) | ||
|
|
||
| return min(0.70, max(0.20, 0.35 * CGFloat(clampedScale))) | ||
| } | ||
|
|
||
| var body: some View { | ||
| // Load control layout data when view appears | ||
| GeometryReader { geometry in | ||
| // Guard against invalid geometry that could cause the view to disappear | ||
| let validSize = geometry.size.width > 0 && geometry.size.height > 0 | ||
| let isLandscape = validSize && geometry.size.width > geometry.size.height | ||
| // Compute once so the background and controller layout always use the same fraction | ||
| let controllerFraction = DefaultControllerSkinView.portraitControllerFraction(scale: controllerScale) | ||
|
|
||
| ZStack { | ||
| // Ensure view always renders even with invalid geometry | ||
|
|
@@ -223,9 +243,9 @@ | |
| if !isLandscape { | ||
| // Portrait mode - show background only in bottom controller area | ||
| VStack(spacing: 0) { | ||
| // Spacer for screen area (top ~65%) - no background here | ||
| // Spacer for screen area — mirrors the game-area spacer below | ||
| Spacer() | ||
| .frame(maxHeight: geometry.size.height * 0.65) | ||
| .frame(maxHeight: geometry.size.height * (1.0 - controllerFraction)) | ||
|
|
||
| // Controller area background with gradual fade | ||
| ZStack { | ||
|
|
@@ -245,32 +265,40 @@ | |
| ) | ||
| ) | ||
| } | ||
| .frame(maxHeight: geometry.size.height * 0.35) | ||
| .opacity(controllerOpacity) | ||
| .frame(maxHeight: geometry.size.height * controllerFraction) | ||
| .clipped() | ||
| } | ||
| } | ||
|
|
||
| if isLandscape { | ||
| // Landscape layout - controls positioned at edges with safe area awareness | ||
| // Landscape layout — scale is applied per-panel inside the skin | ||
| // builder so each side anchors to its screen edge. No whole-view | ||
| // scaleEffect here; that caused controls to clip off-screen. | ||
| dynamicLandscapeControllerSkin | ||
| .opacity(controllerOpacity) | ||
| .onAppear { | ||
| loadControlLayoutData() | ||
| // Ensure input handler has the core set | ||
| inputHandler.setEmulatorCore(coreInstance) | ||
| } | ||
| .edgesIgnoringSafeArea([]) // Respect safe areas for notch | ||
| } else { | ||
| // Portrait layout - controls constrained to bottom area | ||
| // Screen area is typically top ~65%, controller area is bottom ~35% | ||
| // Portrait layout — height allocated proportional to controllerScale; | ||
| // scaleEffect applied so buttons actually resize visually. | ||
| VStack(spacing: 0) { | ||
| // Spacer to push controller to bottom area (top ~65% is screen area) | ||
| // Game area — expands when controls shrink, contracts when they grow | ||
| Spacer() | ||
| .frame(maxHeight: geometry.size.height * 0.65) | ||
| .frame(maxHeight: geometry.size.height * (1.0 - controllerFraction)) | ||
|
|
||
| // Controller area - constrained to bottom portion | ||
| // Controller area — scale content visually, clip overflow, | ||
| // and constrain hit-testing to the allocated frame area. | ||
| dynamicControllerSkin | ||
| .frame(maxHeight: geometry.size.height * 0.35) | ||
| .opacity(controllerOpacity) | ||
| .scaleEffect(CGFloat(controllerScale), anchor: .bottom) | ||
| .frame(maxHeight: geometry.size.height * controllerFraction) | ||
| .clipped() | ||
| .contentShape(Rectangle()) | ||
| .onAppear { | ||
| loadControlLayoutData() | ||
| // Ensure input handler has the core set | ||
|
|
@@ -434,6 +462,17 @@ | |
| } | ||
| } | ||
| ) | ||
| .onChange(of: controllerScale) { _ in | ||
| // Re-emit viewport whenever the scale setting changes so the | ||
| // game render area immediately matches the new controller height. | ||
| guard geometry.size.width > 0 && geometry.size.height > 0 else { return } | ||
| let newIsLandscape = geometry.size.width > geometry.size.height | ||
| emitDefaultViewportIfNeeded( | ||
| size: geometry.size, | ||
| safeInsets: geometry.safeAreaInsets, | ||
| isLandscape: newIsLandscape | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -487,7 +526,8 @@ | |
| } | ||
| } | ||
|
|
||
| // D-pad on the left side | ||
| // D-pad on the left side — scale anchored to leading so buttons grow | ||
| // toward the center, never past the left screen edge. | ||
| VStack { | ||
| Spacer() | ||
| if useJoystickInternal { | ||
|
|
@@ -497,11 +537,13 @@ | |
| } | ||
| Spacer() | ||
| } | ||
| .scaleEffect(CGFloat(controllerScale), anchor: .leading) | ||
| .frame(width: 150) | ||
| .padding(.leading, 80) | ||
| .position(x: 150, y: geometry.size.height / 2) | ||
|
|
||
| // Action buttons on the right side | ||
| // Action buttons on the right side — scale anchored to trailing so buttons | ||
| // grow toward the center, never past the right screen edge. | ||
| VStack { | ||
| Spacer() | ||
| VStack(spacing: 10) { | ||
|
|
@@ -533,6 +575,7 @@ | |
| } | ||
| Spacer() | ||
| } | ||
| .scaleEffect(CGFloat(controllerScale), anchor: .trailing) | ||
| .frame(width: 150) | ||
| .position(x: geometry.size.width - 150, y: geometry.size.height / 2) | ||
| } | ||
|
|
@@ -707,11 +750,16 @@ | |
| return .zero | ||
| } | ||
|
|
||
| // Scale the side reserve to match however large the per-panel scaleEffect | ||
| // makes the landscape controls. Clamped so the game never disappears. | ||
| let controllerScaleVal = CGFloat(Defaults[.controllerScale]) | ||
|
|
||
|
Comment on lines
+753
to
+756
|
||
| let frame: CGRect | ||
| if isLandscape { | ||
| if nativeScaleEnabled { | ||
| /// Native scale: Reserve space for controls on each edge, fit within available space | ||
| let sideReserve = max(180, min(240, safeWidth * 0.25)) | ||
| /// Native scale: Reserve space for controls on each edge, scaled by controllerScale | ||
| let baseSideReserve = max(180, min(240, safeWidth * 0.25)) | ||
| let sideReserve = max(80, min(safeWidth * 0.45, baseSideReserve * controllerScaleVal)) | ||
| let availableWidth = max(0, safeWidth - (sideReserve * 2)) | ||
| var width = availableWidth | ||
| var height = width / aspectRatio | ||
|
|
@@ -726,10 +774,11 @@ | |
| let originX = safeInsets.leading + (safeWidth - width) / 2 | ||
| let originY = safeInsets.top + (safeHeight - height) / 2 | ||
| frame = CGRect(x: originX, y: originY, width: width, height: height) | ||
| ILOG("🎮 SKIN: Default viewport (landscape, native scale): size=\(size), aspectRatio=\(aspectRatio), safeWidth=\(safeWidth), safeHeight=\(safeHeight), availableWidth=\(availableWidth), frame=\(frame)") | ||
| ILOG("🎮 SKIN: Default viewport (landscape, native scale): size=\(size), aspectRatio=\(aspectRatio), safeWidth=\(safeWidth), safeHeight=\(safeHeight), controllerScaleVal=\(controllerScaleVal), sideReserve=\(sideReserve), availableWidth=\(availableWidth), frame=\(frame)") | ||
| } else { | ||
| /// Fullscreen scale: Use more of the available screen space, minimal control reserve | ||
| let sideReserve = max(120, min(160, safeWidth * 0.15)) | ||
| let baseSideReserve = max(120, min(160, safeWidth * 0.15)) | ||
| let sideReserve = max(60, min(safeWidth * 0.40, baseSideReserve * controllerScaleVal)) | ||
| let availableWidth = max(0, safeWidth - (sideReserve * 2)) | ||
|
|
||
| /// Scale to fill available width/height more aggressively | ||
|
|
@@ -761,9 +810,11 @@ | |
| ILOG("🎮 SKIN: Default viewport (landscape, fullscreen scale): size=\(size), aspectRatio=\(aspectRatio), safeWidth=\(safeWidth), safeHeight=\(safeHeight), availableWidth=\(availableWidth), frame=\(frame)") | ||
| } | ||
| } else { | ||
| /// Keep the screen in the upper portion, leaving room for controls | ||
| /// Reserve 35% for controller area, with some margin | ||
| let controllerHeight = safeHeight * 0.35 | ||
| /// Keep the screen in the upper portion, leaving room for controls. | ||
| /// Mirror the same fraction used in the portrait layout view so the | ||
| /// game viewport exactly matches the space above the controller area. | ||
| let controllerFraction = DefaultControllerSkinView.portraitControllerFraction(scale: Double(controllerScaleVal)) | ||
| let controllerHeight = safeHeight * controllerFraction | ||
|
Comment on lines
+813
to
+817
|
||
| /// Ensure minimum top safe area to avoid notch (at least 44pt for status bar/notch area) | ||
| let minTopSafeArea: CGFloat = 44 | ||
| let effectiveTopSafeArea = max(safeInsets.top, minTopSafeArea) | ||
|
|
@@ -1569,7 +1620,8 @@ | |
| } | ||
| } | ||
|
|
||
| // D-pad positioned at left edge | ||
| // D-pad positioned at left edge — scale anchored to leading so buttons | ||
| // grow toward center, never off the left edge of the screen. | ||
| VStack { | ||
| Spacer() | ||
| HStack { | ||
|
|
@@ -1595,14 +1647,18 @@ | |
| .buttonStyle(GameButtonStyle(pressAction: {}, releaseAction: {})) | ||
| } | ||
| } | ||
| // Scale the control content toward the leading (left) edge so | ||
| // it grows inward — never clips past the left screen boundary. | ||
| .scaleEffect(CGFloat(controllerScale), anchor: .leading) | ||
| Spacer() | ||
| } | ||
| .padding(.leading, 80) | ||
| Spacer() | ||
| } | ||
| .frame(width: geometry.size.width, alignment: .leading) | ||
|
|
||
| // Action buttons positioned at right edge using absolute positioning | ||
| // Action buttons positioned at right edge — scale anchored to trailing | ||
| // so buttons grow inward toward center, never off the right screen edge. | ||
| VStack { | ||
| Spacer() | ||
| // Find all button groups in the layout | ||
|
|
@@ -1714,6 +1770,9 @@ | |
| } | ||
| Spacer() | ||
| } | ||
| // Scale the right-panel content toward the trailing (right) edge so it | ||
| // grows inward — never clips past the right screen boundary. | ||
| .scaleEffect(CGFloat(controllerScale), anchor: .trailing) | ||
| .frame(width: 250) // Reduced width to prevent clipping | ||
| .position(x: geometry.size.width - 150, y: geometry.size.height / 2) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In portrait mode,
controllerOpacityis applied todynamicControllerSkinbut not to the controller-area background (RetrowaveBackground+ gradient mask). That means the Opacity slider won’t actually fade the whole overlay panel—only the buttons. If the intent is “overlay opacity”, apply the opacity modifier at a container level that includes both background and controls.