Skip to content

Commit 430ddf9

Browse files
committed
Improve KeyboardControlledRotationComponent
- Adopt AcceleratedValue - Add TimeStep options - Add resetAccelerationWhenChangingDirection - Add logging
1 parent 1712f4c commit 430ddf9

File tree

1 file changed

+81
-26
lines changed

1 file changed

+81
-26
lines changed

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

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

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

2524
public override var requiredComponents: [GKComponent.Type]? {
2625
[KeyboardEventComponent.self,
2726
SpriteKitComponent.self]
2827
}
2928

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

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

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

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

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

5476
@inlinable
5577
public override func update(deltaTime seconds: TimeInterval) {
5678

79+
// #0: If there is no input for this frame, reset the acceleration and exit.
80+
5781
guard
5882
let keyboardEventComponent = coComponent(KeyboardEventComponent.self),
5983
!keyboardEventComponent.codesPressed.isEmpty,
6084
let node = entityNode
6185
else {
62-
acceleratedRadians = baseRadiansPerSecond // TODO: PERFORMANCE: Figure out a better way than setting this every frame.
86+
// TODO: PERFORMANCE: Figure out a better way than setting these every frame.
87+
radiansPerUpdate.reset()
88+
directionForPreviousFrame = 0
6389
return
6490
}
6591

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

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

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

77130
node.zRotation += rotationAmountForCurrentFrame
78131

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

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

0 commit comments

Comments
 (0)