This is my attempt at the May 2017 Community Challenge in Swift, with a chain consisting of rigid links.
I took this as an opportunity to learn SpriteKit, Apple's framework for 2D games. At least Xcode 8.3.2 with Swift 3 is required to compile the code. It runs on both macOS and iOS (instructions below).
VectorUtils.swift – Some helper methods for vector calculations.
import CoreGraphics
let π = CGFloat.pi
extension CGVector {
init(from: CGPoint, to: CGPoint) {
self.init(dx: to.x - from.x, dy: to.y - from.y)
}
func cross(_ other: CGVector) -> CGFloat {
return dx * other.dy - dy * other.dx
}
var length: CGFloat {
return hypot(dx, dy)
}
var arg: CGFloat {
return atan2(dy, dx)
}
}
Sprocket.swift – The type describing a single sprocket.
import CoreGraphics
struct Sprocket {
let center: CGPoint
let radius: CGFloat
let teeth: Int
var clockwise: Bool!
var prevAngle: CGFloat!
var nextAngle: CGFloat!
var prevPoint: CGPoint!
var nextPoint: CGPoint!
init(center: CGPoint, radius: CGFloat) {
self.center = center
self.radius = radius
self.teeth = Int((radius/2).rounded())
}
init(_ triplet: (x: CGFloat, y: CGFloat, r: CGFloat)) {
self.init(center: CGPoint(x: triplet.x, y: triplet.y), radius: triplet.r)
}
// Normalize angles such that
// 0 <= prevAngle < 2π
// and
// prevAngle <= nextAngle < prevAngle + 2π (if rotating counter-clockwise)
// prevAngle - 2π < nextAngle <= prevAngle (if rotating clockwise)
mutating func normalizeAngles() {
prevAngle = prevAngle.truncatingRemainder(dividingBy: 2 * π)
nextAngle = nextAngle.truncatingRemainder(dividingBy: 2 * π)
while prevAngle < 0 {
prevAngle = prevAngle + 2 * π
}
if clockwise {
while nextAngle > prevAngle {
nextAngle = nextAngle - 2 * π
}
} else {
while nextAngle < prevAngle {
nextAngle = nextAngle + 2 * π
}
}
}
mutating func computeTangentPoints() {
prevPoint = CGPoint(x: center.x + radius * cos(prevAngle),
y: center.y + radius * sin(prevAngle))
nextPoint = CGPoint(x: center.x + radius * cos(nextAngle),
y: center.y + radius * sin(nextAngle))
}
}
ChainDrive.swift - The type describing the complete chain drive system. Also contains the code to compute rotation directions, tangent angles/points, and the length of the various segments of the chain.
import CoreGraphics
struct ChainDrive {
var sprockets: [Sprocket]
var length: CGFloat!
var period: CGFloat!
var linkCount: Int!
var accumLength: [(CGFloat, CGFloat)]!
init(sprockets: [Sprocket]) {
self.sprockets = sprockets
computeSprocketData()
computeChainLength()
}
init(_ triplets: [(CGFloat, CGFloat, CGFloat)]) {
self.init(sprockets: triplets.map(Sprocket.init))
}
mutating func computeSprocketData() {
// Compute rotation directions:
for i in 0..<sprockets.count {
let j = (i + 1) % sprockets.count
let k = (j + 1) % sprockets.count
let v1 = CGVector(from: sprockets[j].center, to: sprockets[i].center)
let v2 = CGVector(from: sprockets[j].center, to: sprockets[k].center)
sprockets[j].clockwise = v1.cross(v2) > 0
}
if !sprockets[0].clockwise {
sprockets[1..<sprockets.count].reverse()
for i in 0..<sprockets.count {
sprockets[i].clockwise = !sprockets[i].clockwise
}
}
// Compute tangent angles:
for i in 0..<sprockets.count {
let j = (i + 1) % sprockets.count
let v = CGVector(from: sprockets[i].center, to: sprockets[j].center)
let d = v.length
let a = v.arg
if sprockets[i].clockwise == sprockets[j].clockwise {
var phi = acos((sprockets[i].radius - sprockets[j].radius)/d)
if !sprockets[i].clockwise {
phi = -phi
}
sprockets[i].nextAngle = a + phi
sprockets[j].prevAngle = a + phi
} else {
var phi = acos((sprockets[i].radius + sprockets[j].radius)/d)
if !sprockets[i].clockwise {
phi = -phi
}
sprockets[i].nextAngle = a + phi
sprockets[j].prevAngle = a + phi - π
}
}
// Normalize angles and compute tangent points:
for i in 0..<sprockets.count {
sprockets[i].normalizeAngles()
sprockets[i].computeTangentPoints()
}
}
mutating func computeChainLength() {
accumLength = []
length = 0
for i in 0..<sprockets.count {
let j = (i + 1) % sprockets.count
let l1 = length + abs(sprockets[i].nextAngle - sprockets[i].prevAngle) * sprockets[i].radius
let l2 = l1 + CGVector(from: sprockets[i].nextPoint, to: sprockets[j].prevPoint).length
accumLength.append((l1, l2))
length = l2
}
let count = Int(length / (4 * π))
let p1 = length / CGFloat(count)
let p2 = length / CGFloat(count + 1)
if abs(p1 - 4 * π) <= abs(p2 - 4 * π) {
period = p1
linkCount = count
} else {
period = p2
linkCount = count + 1
}
}
func linkCoordinatesAndPhases(offset: CGFloat) -> ([CGPoint], [CGFloat]) {
var coords: [CGPoint] = []
var phases: [CGFloat] = []
var offset = offset
var total = offset
var i = 0
repeat {
let j = (i + 1) % sprockets.count
let s: CGFloat = sprockets[i].clockwise ? -1 : 1
var phi = sprockets[i].prevAngle + s*offset / sprockets[i].radius
phases.append(phi)
while total <= accumLength[i].0 && coords.count < linkCount {
coords.append(CGPoint(x: sprockets[i].center.x + cos(phi) * sprockets[i].radius,
y: sprockets[i].center.y + sin(phi) * sprockets[i].radius))
phi += s * period / sprockets[i].radius
total += period
}
var d = total - accumLength[i].0
let v = CGVector(from: sprockets[i].nextPoint, to: sprockets[j].prevPoint)
while total <= accumLength[i].1 && coords.count < linkCount {
coords.append(CGPoint(x: sprockets[i].nextPoint.x + d * v.dx / v.length,
y: sprockets[i].nextPoint.y + d * v.dy / v.length))
d += period
total += period
}
offset = total - accumLength[i].1
i = j
} while coords.count < linkCount
return (coords, phases)
}
}
SprocketNode.swift – Defines a SKShapeNode
subclass for drawing
a single sprocket.
import SpriteKit
class SprocketNode: SKShapeNode {
let radius: CGFloat
let clockwise: Bool
let teeth: Int
init(sprocket: Sprocket) {
self.radius = sprocket.radius
self.clockwise = sprocket.clockwise
self.teeth = sprocket.teeth
super.init()
let path = CGMutablePath()
path.move(to: CGPoint(x: radius - 2, y: 0))
for i in 0..<teeth {
let a1 = π * CGFloat(4 * i - 1)/CGFloat(2 * teeth)
let a2 = π * CGFloat(4 * i + 1)/CGFloat(2 * teeth)
let a3 = π * CGFloat(4 * i + 3)/CGFloat(2 * teeth)
path.addArc(center: CGPoint.zero, radius: radius - 2,
startAngle: a1, endAngle: a2, clockwise: false)
path.addArc(center: CGPoint.zero, radius: radius + 2,
startAngle: a2, endAngle: a3, clockwise: false)
}
path.closeSubpath()
self.path = path
self.lineWidth = 0
self.fillColor = SKColor(red: 0x86/255, green: 0x84/255, blue: 0x81/255, alpha: 1) // #868481
self.strokeColor = .clear
self.position = sprocket.center
do {
let path = CGMutablePath()
path.addEllipse(in: CGRect(x: -3, y: -3, width: 6, height: 6))
path.addEllipse(in: CGRect(x: -radius + 4.5, y: -radius + 4.5,
width: 2 * radius - 9, height: 2 * radius - 9))
let node = SKShapeNode(path: path)
node.fillColor = SKColor(red: 0x64/255, green: 0x63/255, blue: 0x61/255, alpha: 1) // #646361
node.lineWidth = 0
node.strokeColor = .clear
self.addChild(node)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
LinkNode.swift – Defines a SKShapeNode
subclass for drawing
a chain link.
import SpriteKit
class LinkNode: SKShapeNode {
static let narrowWidth: CGFloat = 2
static let wideWidth : CGFloat = 6
let pitch: CGFloat
init(pitch: CGFloat) {
self.pitch = pitch
super.init()
let phi = asin(LinkNode.narrowWidth / LinkNode.wideWidth)
let path = CGMutablePath()
path.addArc(center: CGPoint(x: -pitch/2, y: 0), radius: LinkNode.wideWidth/2,
startAngle: phi, endAngle: 2 * π - phi, clockwise: false)
path.addLine(to: CGPoint(x: pitch/2, y: -LinkNode.narrowWidth/2))
path.addArc(center: CGPoint(x: pitch/2, y: 0), radius: LinkNode.narrowWidth/2,
startAngle: -π/2, endAngle: π/2, clockwise: false)
path.closeSubpath()
self.path = path
self.fillColor = .black
self.lineWidth = 0
self.strokeColor = .clear
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func moveTo(leftPin: CGPoint, rightPin: CGPoint) {
position = CGPoint(x: (leftPin.x + rightPin.x)/2,
y: (leftPin.y + rightPin.y)/2)
zRotation = CGVector(from: leftPin, to: rightPin).arg
}
}
ChainDriveScene.swift – Defines a SKScene
subclass for drawing
and animating the chain drive.
import SpriteKit
typealias Triples = [(CGFloat, CGFloat, CGFloat)]
// The system from the challenge: https://codereview.meta.stackexchange.com/a/7264 :
let system0: Triples = [(0, 0, 16), (100, 0, 16), (100, 100, 12), (50, 50, 24), (0, 100, 12)]
// Other systems from https://codegolf.stackexchange.com/q/64764:
let system1: Triples = [(0, 0, 26), (120, 0, 26)]
let system2: Triples = [(100, 100, 60), (220, 100, 14)]
let system3: Triples = [(100, 100, 16), (100, 0, 24), (0, 100, 24), (0, 0, 16)]
let system4: Triples = [(0, 0, 60), (44, 140, 16), (-204, 140, 16), (-160, 0, 60), (-112, 188, 12),
(-190, 300, 30), (30, 300, 30), (-48, 188, 12)]
let system5: Triples = [(0, 128, 14), (46.17, 63.55, 10), (121.74, 39.55, 14), (74.71, -24.28, 10),
(75.24, -103.55, 14), (0, -78.56, 10), (-75.24, -103.55, 14),
(-74.71, -24.28, 10), (-121.74, 39.55, 14), (-46.17, 63.55, 10)]
let system6: Triples = [(367, 151, 12), (210, 75, 36), (57, 286, 38), (14, 181, 32), (91, 124, 18),
(298, 366, 38), (141, 3, 52), (80, 179, 26), (313, 32, 26), (146, 280, 10),
(126, 253, 8), (220, 184, 24), (135, 332, 8), (365, 296, 50), (248, 217, 8),
(218, 392, 30)]
class ChainDriveScene: SKScene {
let chainDrive: ChainDrive
let chainSpeed = 16 * π // speed (points/sec)
var initialTime: TimeInterval!
var sprocketNodes: [SprocketNode] = []
var linkNodes: [LinkNode] = []
class func newScene() -> ChainDriveScene {
let system = ChainDrive(system0)
return ChainDriveScene(system: system)
}
init(system: ChainDrive) {
self.chainDrive = system
let minx = system.sprockets.map { $0.center.x - $0.radius }.min()! - 15
let miny = system.sprockets.map { $0.center.y - $0.radius }.min()! - 15
let maxx = system.sprockets.map { $0.center.x + $0.radius }.max()! + 15
let maxy = system.sprockets.map { $0.center.y + $0.radius }.max()! + 15
super.init(size: CGSize(width: maxx - minx, height: maxy - miny))
self.anchorPoint = CGPoint(x: -minx/(maxx - minx), y: -miny/(maxy - miny))
self.scaleMode = .aspectFit
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setUpScene() {
backgroundColor = .white
sprocketNodes = chainDrive.sprockets.map(SprocketNode.init)
for node in sprocketNodes {
self.addChild(node)
}
let (coords, _) = chainDrive.linkCoordinatesAndPhases(offset: 0)
for i in 0..<coords.count {
let j = (i + 1) % coords.count
let node = LinkNode(pitch: chainDrive.period)
node.moveTo(leftPin: coords[i], rightPin: coords[j])
self.addChild(node)
linkNodes.append(node)
}
}
override func didMove(to view: SKView) {
self.setUpScene()
}
override func update(_ currentTime: TimeInterval) {
if initialTime == nil {
initialTime = currentTime
}
let distance = CGFloat(currentTime - initialTime) * chainSpeed * speed
let k = Int(distance/chainDrive.period) % linkNodes.count
let offset = distance.truncatingRemainder(dividingBy: chainDrive.period)
let (coords, phases) = chainDrive.linkCoordinatesAndPhases(offset: offset)
for i in 0..<linkNodes.count {
let p1 = coords[i % coords.count]
let p2 = coords[(i + 1) % coords.count]
linkNodes[(i + linkNodes.count - k) % linkNodes.count].moveTo(leftPin: p1, rightPin: p2)
}
for i in 0..<phases.count {
sprocketNodes[i].zRotation = phases[i]
}
}
}
The complete project is available on GitHub. Alternatively:
In Xcode 8.3.2 (or later), create a new project from the "Cross-platform SpriteKit Game" template.
Select "Include iOS Application" and/or "Include macOS Application".
Add the above source files to the project.
In the GameViewController.swift files, replace
let scene = GameScene.newGameScene()
by
let scene = ChainDriveScene.newScene()
- Compile and run!
The animation runs with approx 60 frames per second, both on a 1.2 GHz MacBook and on an iPhone 6s. To give you a rough impression of what it looks like, I took a screen recording with QuickTime Player and converted it to an animated GIF with ffmpeg and gifsicle:
All feedback is welcome, such as (but not limited to):
- Can the geometrical computations be simplified?
- Are there any better type/variable/function names?
- There are several "implicitly unwrapped optional" properties
in
struct Sprocket
. The reason is that these are computed (infunc computeSprocketData()
) after all sprockets have been initialized. Any suggestions on how to do this two-step initialization more elegantly? - Initially I used a
SKAction
for rotating the sprockets, but did not find a way to animate the chain withSKActions
. Therefore both sprockets and chain links are now updated in theupdate()
method (which is called for each frame). Is there a better way to achieve the same result? - Another idea was to use
SKAction.followPath()
to animate the chain links. That worked well for one link, but I could not figure out how to make the other links follow the same path with a delay. Is that possible? - This is my first SpriteKit project, therefore any advice on how to make more idiomatic use of that framework is appreciated.
/
operator, for one :P \$\endgroup\$