53
\$\begingroup\$

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:

Simple animated chain network, 5 gears Complicated animated chain network, 16 gears

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 (in func 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 with SKActions. Therefore both sprockets and chain links are now updated in the update() 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.
\$\endgroup\$
5
  • 2
    \$\begingroup\$ @Linny: It was very nice of you to offer a bounty on this question. Unfortunately, it was as unsuccessful as the previous two bounties. \$\endgroup\$
    – Martin R
    Jan 22, 2020 at 12:10
  • 1
    \$\begingroup\$ One has to try :). \$\endgroup\$
    – Linny
    Jan 22, 2020 at 14:37
  • 1
    \$\begingroup\$ I wonder... either we don't have enough experienced Swift people or everyone finds your code perfect. \$\endgroup\$
    – S.S. Anne
    Jan 27, 2020 at 15:45
  • 1
    \$\begingroup\$ No code is perfect. I don't see spaces around the / operator, for one :P \$\endgroup\$ Jan 28, 2020 at 5:09
  • 1
    \$\begingroup\$ @MaartenBodewes Write a review some of us old timers will probably vote it up. \$\endgroup\$
    – pacmaninbw
    May 10, 2021 at 18:08

1 Answer 1

3
\$\begingroup\$

Perhaps the fact that this has gone unanswered for quite some time is a testament to the quality of the code. It appears the code is separated well into individual files and indentation is consistent.

Some could argue that there should be more comments, though the code is somewhat self-documenting as methods are named appropriately. Some names are a bit overly brief - e.g. var d within ChainDrive (in multiple functions). In some cases this is assigned to a difference - for example: in the repeat loop within linkCoordinatesAndPhases()

var d = total - accumLength[i].0

and then in the sub-sequent while loop values are added to it:

d += period

in other cases it is simply assigned to the length of a vector - e.g. in the loop within computeSprocketData()

let v = CGVector(from: sprockets[i].center, to: sprockets[j].center)
let d = v.length
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.