@@ -19,70 +19,125 @@ import GameplayKit
1919@available ( macOS 10 . 15 , * )
2020public 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