Opt-in "Gyro from this device" (DefaultsKey.gyroFromDevice, off by default, iOS only): while player 1's forwarded controller reports no rotation rate of its own — no GCMotion, or the gravity-only motion an Xbox pad exposes — this device's IMU sources pad 0's wire motion instead. The rumble-on-device mirror's sibling, data flowing the other way: same session-scoped UserDefaults read, same hardware-gated settings rows, same pad-0 rule. DeviceGyro wraps CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a dedicated serial queue (not main — the controller path's main-queue delivery is a known jitter source), converts with the shared GamepadWire constants, and rotates each sample from the device's portrait frame into the controller frame by interface orientation, so a phone clipped landscape yaws when the player yaws instead of rolling. The remap matrix is derived and pinned by DeviceGyroRemapTests. GamepadCapture owns engage/stand-down (reconcile, suspend/resume, stop), and suppresses pad 0's controller-motion forwarding while the mirror runs — two writers on one pad's motion state would fight, and the accel-only stream would stomp the mirror's gyro with zeros. Also fixes the stale-motion latch from the gyro sweep on the controller path: flush now parks motion at zero (keeping the last accel, so gravity doesn't become free-fall), and the mirror's stop sends the same closing zero. The host holds motion as state and re-emits it — a nonzero angular velocity left behind read as endless rotation for as long as an overlay (Control Center pull-down) kept the app inactive.
63 lines
2.6 KiB
Swift
63 lines
2.6 KiB
Swift
// Pins the phone-gyro mirror's device→controller frame remap (DeviceGyro.swift). The matrix is
|
||
// derived (like the wire scale constants), so these tests are the contract: if on-glass says an
|
||
// axis is wrong, fix the enum AND these expectations together.
|
||
|
||
#if os(iOS)
|
||
import UIKit
|
||
import XCTest
|
||
|
||
@testable import PunktfunkKit
|
||
|
||
final class DeviceGyroRemapTests: XCTestCase {
|
||
/// A distinct vector per axis so a swapped or flipped component can't cancel out.
|
||
private let v: (x: Float, y: Float, z: Float) = (1, 2, 3)
|
||
|
||
func testPortraitIsIdentity() {
|
||
let r = DeviceGyroRemap.identity.apply(x: v.x, y: v.y, z: v.z)
|
||
XCTAssertEqual([r.x, r.y, r.z], [1, 2, 3])
|
||
}
|
||
|
||
func testUpsideDownFlipsInPlane() {
|
||
let r = DeviceGyroRemap.flipped.apply(x: v.x, y: v.y, z: v.z)
|
||
XCTAssertEqual([r.x, r.y, r.z], [-1, -2, 3])
|
||
}
|
||
|
||
/// Device top to the player's LEFT: player-right = device-bottom (−y), player-up =
|
||
/// device-right (+x). z (out of the screen) never changes — the screen faces the player.
|
||
func testTopLeftLandscape() {
|
||
let r = DeviceGyroRemap.topLeft.apply(x: v.x, y: v.y, z: v.z)
|
||
XCTAssertEqual([r.x, r.y, r.z], [-2, 1, 3])
|
||
}
|
||
|
||
/// Device top to the player's RIGHT: player-right = device-top (+y), player-up =
|
||
/// device-left (−x).
|
||
func testTopRightLandscape() {
|
||
let r = DeviceGyroRemap.topRight.apply(x: v.x, y: v.y, z: v.z)
|
||
XCTAssertEqual([r.x, r.y, r.z], [2, -1, 3])
|
||
}
|
||
|
||
/// Interface orientation → remap: `.landscapeRight` means the Home edge is on the
|
||
/// player's right, i.e. the device top points LEFT (and vice versa).
|
||
func testOrientationMapping() {
|
||
XCTAssertEqual(DeviceGyroRemap(.portrait), .identity)
|
||
XCTAssertEqual(DeviceGyroRemap(.portraitUpsideDown), .flipped)
|
||
XCTAssertEqual(DeviceGyroRemap(.landscapeRight), .topLeft)
|
||
XCTAssertEqual(DeviceGyroRemap(.landscapeLeft), .topRight)
|
||
XCTAssertEqual(DeviceGyroRemap(.unknown), .identity)
|
||
}
|
||
|
||
/// Every remap must stay a proper rotation (right-handed): x̂ × ŷ = ẑ after mapping.
|
||
func testHandednessPreserved() {
|
||
for remap in [DeviceGyroRemap.identity, .flipped, .topLeft, .topRight] {
|
||
let x = remap.apply(x: 1, y: 0, z: 0)
|
||
let y = remap.apply(x: 0, y: 1, z: 0)
|
||
// Cross product of the two mapped in-plane basis vectors.
|
||
let cross = (
|
||
x: x.y * 0 - 0 * y.y, y: 0 * y.x - x.x * 0, z: x.x * y.y - x.y * y.x
|
||
)
|
||
XCTAssertEqual(cross.z, 1, "left-handed remap: \(remap)")
|
||
}
|
||
}
|
||
}
|
||
#endif
|