Skip to content

Commit 915c30b

Browse files
committed
Improve KeyboardControlledRotationComponent
- Adopt AcceleratedValue - Add resetAccelerationWhenChangingDirection - Add logging
1 parent 1712f4c commit 915c30b

File tree

1 file changed

+81
-25
lines changed

1 file changed

+81
-25
lines changed

Sources/OctopusKit/Components/Input/Keyboard/KeyboardControlledRotationComponent.swift

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import GameplayKit
1919
@available(macOS 10.15, *)
2020
public final class KeyboardControlledRotationComponent: OctopusComponent, OctopusUpdatableComponent {
2121

22-
// TODO: TimeStep options
22+
// TODO: Tests
2323
// TODO: Reset the acceleration when the direction reverses, as that is more natural.
2424

2525
public override var requiredComponents: [GKComponent.Type]? {
@@ -28,61 +28,117 @@ public final class KeyboardControlledRotationComponent: OctopusComponent, Octopu
2828
}
2929

3030
/// Change this to a different code to customize the keys.
31-
public var arrowRight: UInt16 = .arrowRight
31+
public var arrowRight: UInt16 = .arrowRight
32+
3233
/// Change this to a different code to customize the keys.
33-
public var arrowLeft: UInt16 = .arrowLeft
34+
public var arrowLeft: UInt16 = .arrowLeft
35+
36+
/// The amount to rotate the node by in a single update, with optional acceleration.
37+
public var radiansPerUpdate: AcceleratedValue<CGFloat>
38+
39+
/// Specifies a fixed or variable timestep for per-update changes.
40+
public var timestep: TimeStep
3441

35-
/// The minimum amount to rotate the node by in a single second.
36-
public var baseRadiansPerSecond: CGFloat
42+
/// If `true`, `radiansPerUpdate` is reset to its base value when there is no rotation, for realistic inertia.
43+
///
44+
/// `radiansPerUpdate` is always reset when there is no player input.
45+
public var resetAccelerationWhenChangingDirection: Bool
3746

38-
public var maximumRadiansPerSecond: CGFloat
39-
public var acceleratedRadians: CGFloat = 0
40-
public var accelerationPerSecond: CGFloat
47+
/// Records the previous direction for use with `resetAccelerationWhenChangingDirection`, where `1` is counter-clockwise, `-1` is clockwise, and `0` is stationary.
48+
public var directionForPreviousFrame: Int = 0 // Not private(set) so update(deltaTime:) can be @inlinable
4149

42-
public init(baseRadiansPerSecond: CGFloat = 2.0, // ÷ 60 per frame
43-
maximumRadiansPerSecond: CGFloat = 4.0,
44-
accelerationPerSecond: CGFloat = 2.0)
50+
public init(radiansPerUpdate: AcceleratedValue<CGFloat>,
51+
timestep: TimeStep = .perSecond,
52+
resetAccelerationWhenChangingDirection: Bool = true)
4553
{
46-
self.baseRadiansPerSecond = baseRadiansPerSecond
47-
self.maximumRadiansPerSecond = maximumRadiansPerSecond
48-
self.accelerationPerSecond = accelerationPerSecond
54+
self.radiansPerUpdate = radiansPerUpdate
55+
self.timestep = timestep
56+
self.resetAccelerationWhenChangingDirection = resetAccelerationWhenChangingDirection
4957
super.init()
5058
}
59+
60+
public convenience init(radiansPerUpdate: CGFloat = 1.0, // ÷ 60 = 0.01666666667 per frame
61+
acceleration: CGFloat = 0,
62+
maximum: CGFloat = 1.0,
63+
timestep: TimeStep = .perSecond,
64+
resetAccelerationWhenChangingDirection: Bool = true)
65+
{
66+
self.init(radiansPerUpdate: AcceleratedValue<CGFloat>(base: radiansPerUpdate,
67+
current: radiansPerUpdate,
68+
maximum: maximum,
69+
minimum: 0,
70+
acceleration: acceleration),
71+
timestep: timestep,
72+
resetAccelerationWhenChangingDirection: resetAccelerationWhenChangingDirection)
73+
}
5174

5275
public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
5376

5477
@inlinable
5578
public override func update(deltaTime seconds: TimeInterval) {
5679

80+
// #0: If there is no input for this frame, reset the acceleration and exit.
81+
5782
guard
5883
let keyboardEventComponent = coComponent(KeyboardEventComponent.self),
5984
!keyboardEventComponent.codesPressed.isEmpty,
6085
let node = entityNode
6186
else {
62-
acceleratedRadians = baseRadiansPerSecond // TODO: PERFORMANCE: Figure out a better way than setting this every frame.
87+
// TODO: PERFORMANCE: Figure out a better way than setting these every frame.
88+
radiansPerUpdate.reset()
89+
directionForPreviousFrame = 0
6390
return
6491
}
6592

66-
// Did player press a directional arrow key?
93+
// #1: Did player press a directional arrow key?
94+
6795
// ❕ NOTE: Don't use `switch` or `else` because we want to process multiple keypresses, to cancel out opposing directions.
6896
// ❕ NOTE: Positive rotation = counter-clockwise :)
6997

7098
let codesPressed = keyboardEventComponent.codesPressed
71-
let radiansForCurrentFrame = acceleratedRadians * CGFloat(seconds)
99+
var directionForCurrentFrame: Int = 0
100+
101+
if codesPressed.contains(self.arrowRight) { directionForCurrentFrame += 1 } // ➡️
102+
if codesPressed.contains(self.arrowLeft) { directionForCurrentFrame -= 1 } // ➡️
103+
104+
// #2: Reset the acceleration if the player changed the direction between updates.
105+
106+
if resetAccelerationWhenChangingDirection,
107+
self.directionForPreviousFrame != directionForCurrentFrame
108+
{
109+
radiansPerUpdate.reset()
110+
}
111+
112+
self.directionForPreviousFrame = directionForCurrentFrame
113+
114+
// #3: Exit if multiple directional inputs cancel each other out, this prevents accumulation of acceleration when there is no movement.
115+
116+
guard directionForCurrentFrame != 0 else { return }
117+
118+
// #4: Apply the rotation.
119+
120+
let radiansForCurrentFrame = timestep.applying(radiansPerUpdate.current, deltaTime: CGFloat(seconds))
72121
var rotationAmountForCurrentFrame: CGFloat = 0
73122

74-
if codesPressed.contains(self.arrowRight) { rotationAmountForCurrentFrame -= radiansForCurrentFrame } // ➡️
75-
if codesPressed.contains(self.arrowLeft) { rotationAmountForCurrentFrame += radiansForCurrentFrame } // ⬅️
123+
if codesPressed.contains(self.arrowRight) { // ➡️
124+
rotationAmountForCurrentFrame -= radiansForCurrentFrame
125+
}
126+
127+
if codesPressed.contains(self.arrowLeft) { // ⬅️
128+
rotationAmountForCurrentFrame += radiansForCurrentFrame
129+
}
76130

77131
node.zRotation += rotationAmountForCurrentFrame
78132

79-
// Apply acceleration for the next frame.
133+
#if LOGINPUTEVENTS
134+
debugLog("node.zRotation = \(node.zRotation), rotationAmountForCurrentFrame = \(rotationAmountForCurrentFrame), radiansPerUpdate = \(radiansPerUpdate), \(timestep)")
135+
#endif
136+
137+
// #5: Apply any acceleration, and clamp the speed to the pre-specified bounds.
80138

81-
if acceleratedRadians < maximumRadiansPerSecond {
82-
acceleratedRadians += (accelerationPerSecond * CGFloat(seconds))
83-
if acceleratedRadians > maximumRadiansPerSecond {
84-
acceleratedRadians = maximumRadiansPerSecond
85-
}
139+
if radiansPerUpdate.isWithinBounds { // CHECK: PERFORMANCE
140+
radiansPerUpdate.update(timestep: timestep, deltaTime: CGFloat(seconds))
141+
radiansPerUpdate.clamp()
86142
}
87143
}
88144
}

0 commit comments

Comments
 (0)