Many years ago, I wrote a simple friction gear simulation in Flash. I thought it would be an interesting exercise to repeat in Swift that could be made even more interesting by adding the ever excellent AudioKit (which have just reached version 2.1) libraries for some musical accompaniment. So was born, WheelTone.
WheelTone allows users to create a network of friction gears. Once created, by a long press gesture on the background, wheels can be drag-moved or resized with a pinch gesture. When two wheels are in contact, they engage and rotate accordingly.
A long press on a newly created wheel toggles whether it plays a tone. Audio enabled wheels have a radial line and when that line hits 12 o’clock, the wheel plays an AudioKit vibes instrument with a frequency which is a function of its radius.
The first wheel is the source of the rotation, so subsequent wheels need to touch that, or a wheel touching that, to rotate.
The basic mechanics of WheelTone are pretty simple. To get the mechanics of the gear wheel network up and running, I only use two classes, a view controller and a WheelWidget.
Let’s look at WheelWidget first. This is an extended CAShapeLayer with properties that describe its origin, radius and frequency. It has a sublayer, gearShape, which uses lineDashPattern to give the dashed border.
Along with a ‘regular’ init() that requires a radius, origin and conductor (more about this later), I need to override init(layer: AnyObject!). This initialiser is used by CoreAnimation to create shadow copies during animations and since radius, origin and conductor all require values at initialisation, my overridden version looks like this:
override init(layer: AnyObject!)
{
if let layer = layer as? WheelWidget
{
radius = layer.radius
origin = layer.origin
conductor = layer.conductor
}
else
{
radius = 0
origin = CGPointZero
conductor = nil
}
super.init(layer: layer)
}
In my main init(), I’ve set WheelWidget to be the delegate for itself and its gearShape:
delegate = self
gearShape.delegate = self
This allows me to override the actionForLayer() method where I can define animations for different events. In my case, I wanted to disable all animations for the gear shape layer and reduce the default duration for a position change on the wheel widget:
override func actionForLayer(layer: CALayer!, forKey event: String!) -> CAAction!
{
if layer == gearShape
{
return NSNull()
}
else if layer == self && (event == "onDraw" || event == "contents")
{
return NSNull()
}
else if layer == self && event == "position"
{
let animation = CABasicAnimation(keyPath: event)
animation.duration = 0.075
return animation
}
else
{
return nil
}
}
You’ll notice something interesting in actionForLayer() - where I want to explicitly disable an animation, I return NSNull() and where I want to simply use the default animation, I return nil.
When WheelWidget’s radius, rotation or origin change, I want to force a redraw. I could add that redraw code to the didSet observers, but it’s better to invalidate the layout and use the layout manager’s layoutSublayers() method to do the drawing in the next update.
For example, when the wheel widget’s rotation property is updated, I set a flag indicating the change and call setNeedsLayout() to mark that layoutSublayers() needs to be called at the next update:
var rotation: CGFloat = 0
{
didSet
{
rotationChanged = true
setNeedsLayout()
}
}
Then, in my overridden layoutSublayers(), I check the rotationChanged flag and if true, create a new elliptical path with an affine transform for my gearShape.
override func layoutSublayers()
{
[...]
if rotationChanged
{
var rotateTransform = CGAffineTransformMakeRotation(rotation)
let gearPath = CGPathCreateMutable()
CGPathAddEllipseInRect(gearPath, &rotateTransform, boundingBox)
gearShape.path = gearPath
rotationChanged = false
}
[...]
}
When the radius changes, I update the gear shape’s lineDashPattern with a dash length of one fiftieth of the wheel widget’s circumference which ensures the dashes always wrap around the shape properly:
if radiusChanged
{
gearShape.lineDashPattern = [circumference / 50]
path = CGPathCreateWithEllipseInRect(boundingBox, nil)
radiusChanged = false
}
The view controller uses three separate gesture recognisers:
- A UIPinchGestureRecognizer which is used to resize wheel widgets.
- A UIPanGestureRecognizer which is uses to move wheel widgets around the screen
- A UILongPressGestureRecognizer which is used to create new wheel widgets or toggle their audio on or off.
When it initialises, the view controller defines a first instance of a WheelWidget which it places in the centre of the screen. As I mentioned above, it’s this first instance which is the source of rotation, so all additional wheel widgets need to be directly or indirectly in contact with it.
When the AppDelegate’s applicationDidBecomeActive() method is invoked, it starts the rotating animation on the view controller though the start() method:
func applicationDidBecomeActive(application: UIApplication)
{
(window?.rootViewController as? ViewController)?.start()
}
The view controller’s start() method creates a repeating timer that calls step() forty times a second:
func start()
{
NSTimer.scheduledTimerWithTimeInterval(1 / 40, target: self, selector: "step", userInfo: nil, repeats: true)
}
The step() method clears down an array holding all the updated wheels in this step, increments the first wheel’s rotation and updates all the immediate wheels with the updateAdjacentWheelWidgets() method:
func step()
{
firstWheelWidget.rotation += 0.025
rotatedWidgets = [WheelWidget]()
updateAdjacentWheelWidgets(firstWheelWidget, angle: firstWheelWidget.rotation)
}
updateAdjacentWheelWidgets() is a recursive function. It checks every wheel widget in the view controller’s layer, checks if they are touching the wheel widget passed in as an argument and, if they are, updates the angle and calls updateAdjacentWheelWidgets() on that newly updated wheel. The rotatedWidgets array is appended with each updated when and acts as a guard to prevent wheels being updated by more than one other and infinite loops:
func updateAdjacentWheelWidgets(sourceWidget: WheelWidget, angle: CGFloat)
{
rotatedWidgets.append(sourceWidget)
for sublayer in view.layer.sublayers
{
if let targetWidget = sublayer as? WheelWidget where
find(rotatedWidgets, targetWidget) == nil &&
targetWidget != sourceWidget && abs(sourceWidget.origin.distance(targetWidget.origin) - (sourceWidget.radius + targetWidget.radius)) < 8
{
let newAngle = -angle * (sourceWidget.radius / targetWidget.radius)
targetWidget.rotation = newAngle
updateAdjacentWheelWidgets(targetWidget, angle: newAngle)
}
}
}
The final part of the app is to play a tone each time a wheel widget hits the 12 o’clock mark. For this, I’ve copied the conductor pattern design by AudioKit’s Aurelius Prochazka which I used in my Spritely app. The view controller instantiates an instance of Conductor class which is passed to each wheel widget in its constructor:
let conductor = Conductor()
[...]
firstWheelWidget = WheelWidget(radius: 100, origin: CGPointZero, conductor: conductor)
Inside the wheel widget’s rotation didSet() observer, I set the rotationCount as an integer based on rotation and when that count increments, I simply invoke play() on the conductor:
var rotation: CGFloat = 0
{
didSet
{
rotationCount = Int(rotation / CGFloat(M_PI * 2))
if let frequency = frequency where lastPingedRotationCount != rotationCount
{
conductor?.play(frequency: Float(frequency), amplitude: 0.15, instrument: Instruments.vibes)
lastPingedRotationCount = rotationCount
}
}
}
The wheel’s frequency is inversely proportional to its radius so that small wheels play a high note and large wheels play a low note. Inside the WheelWidget class, I have a static constant array of frequencies that map to musical notes:
static let frequencies: [CGFloat] = [130.813, 138.591, 146.832, 155.563, 164.814, ...
…and a simple utility function picks a note from that array based on a radius:
class func getFrequencyForRadius(radius: CGFloat) -> CGFloat
{
let index = Int(round((radius - minRadius) / (maxRadius - minRadius) * CGFloat(frequencies.count - 1)))
return frequencies[index]
}
And that’s all there is to it!
The full source code is available at my GitHub repository and you can find out more about AudioKit at their site. Finally, here's a direct screen recording which may illustrate the gear rotation slightly better than the vide above:
Cute! What happenes if gear touches 2 gears turning in opposing directions? Can the rotation speed and direction be altered?
ReplyDeleteHi Pete, only the first touching wheel is recognised, others are ignored. No support yet for changing speed or direction I'm afraid. You could raise a pull request :)
ReplyDeleteI re-read it, well read it properly and saw that. However, if you have 2 opposing wheels and another touching both what happens then? First or last win or some enormous Metal particle effect explosion :-)
ReplyDelete:)
ReplyDeleteIt just picks the first one in the array, then ignores subsequent ones.