Swift: how to invalidate timer on animated SKShapenode countdown? - swift

Alright I'm building a sprite kit game and on one of my previous questions, I got the code that lets a circle skshapenode "unwind" in accordance with a timer that runs out (so that the circle has disappeared by the time the timer is done).
Here was my question: Swift, sprite kit game: Have circle disappear in clockwise manner? On timer?
And here is the code that I've been using:
func addCircle() {
circle = SKShapeNode(circleOfRadius: 50)
let circleColor = UIColor(red: 102/255, green: 204/255, blue: 255/255, alpha: 1)
circle.fillColor = circleColor
circle.strokeColor = SKColor.clearColor()
circle.position = CGPoint (x: self.frame.size.width * 0.5, y: self.frame.size.height * 0.5+45)
circle.zPosition = 200
circle.zRotation = CGFloat(M_PI_2)
addChild(circle)
countdown(circle, steps: 120, duration: 5) { //change steps to change how much of the circle it takes away at a time
//Performed when circle timer ends:
print("circle done")
self.gameSoundTrack.stop()
self.removeAllChildren()
self.removeAllActions()
self.viewController.removeSaveMe(self)
self.GOSceneNodes() //Implements GOScene
self.viewController.addGOScene(self) //Implements GOViewController
}
}
// Creates an animated countdown timer
func countdown(circle:SKShapeNode, steps:Int, duration:NSTimeInterval, completion:()->Void) {
let radius = CGPathGetBoundingBox(circle.path).width/2
let timeInterval = duration/NSTimeInterval(steps)
let incr = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.runBlock({
percent -= incr
circle.path = self.circle(radius, percent:percent)
})
let wait = SKAction.waitForDuration(timeInterval)
let action = SKAction.sequence([wait, animate])
runAction(SKAction.repeatAction(action,count:steps-1)) {
self.runAction(SKAction.waitForDuration(timeInterval)) {
circle.path = nil
completion()
}
}
}
// Creates a CGPath in the shape of a pie with slices missing
func circle(radius:CGFloat, percent:CGFloat) -> CGPath {
let start:CGFloat = 0
let end = CGFloat(M_PI*2) * percent
let center = CGPointZero
let bezierPath = UIBezierPath()
bezierPath.moveToPoint(center)
bezierPath.addArcWithCenter(center, radius: radius, startAngle: start, endAngle: end, clockwise: true)
bezierPath.addLineToPoint(center)
return bezierPath.CGPath
}
This all works well, however I need a way to invalidate the countdown for the circle if another button within the game is pressed so that everything that is performed when the timer runs out (everything after //Performed when circle timer ends: above) is NOT performed.
I have tried
circle.removeAllActions()
circle.removeFromParent()
when the button is pressed, however this just removes the circle and everything is still executed even though the circle is gone.
I'm pretty new to Swift and I can't figure out what part of those 3 functions I need to invalidate or stop in order to stop the countdown from finishing. How can I accomplish this?

You can stop an action by adding a key to the action and then run
removeActionForKey("actionKey")
To add a key to the action, replace the countdown method with the following:
// Creates an animated countdown timer
func countdown(circle:SKShapeNode, steps:Int, duration:NSTimeInterval, completion:()->Void) {
let radius = CGPathGetBoundingBox(circle.path).width/2
let timeInterval = duration/NSTimeInterval(steps)
let incr = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.runBlock({
percent -= incr
circle.path = self.circle(radius, percent:percent)
})
let wait = SKAction.waitForDuration(timeInterval)
let action = SKAction.sequence([wait, animate])
let completed = SKAction.runBlock{
circle.path = nil
completion()
}
let countDown = SKAction.repeatAction(action,count:steps-1)
let sequence = SKAction.sequence([countDown, SKAction.waitForDuration(timeInterval),completed])
runAction(sequence, withKey: "actionKey")
}

Related

My SKSpriteNode speed values are not updating properly

What I'm trying to do is update my SKSpriteNodes so I can change their scrolling speeds dynamically, however they aren't really working consistently. I didn't include the code, but I have another method with a switch case that sets the value of platformSpeed whenever the state is changed (in this case, the switch case is changed with UIButtons). In my code I have an SKSpriteNode array and a platformSpeed property that includes didSet so my value is updated properly.
In my method to create the platforms, I grouped my SpriteNodes into platformGroup then looped through them with addChild(). Not sure why it's acting this way but here's a quick video of what it looks like in action:
demonstration clip
So with the buttons I'm changing the switch case, and as you can see, not all of the nodes speeds are updating properly and some get faster than others and eventually pass them. I need them to stay equal distance between each other.
Now here's my code:
class GameScene: SKScene, SKPhysicsContactDelegate {
var platformGroup = [SKSpriteNode]()
var platformSpeed: CGFloat = 1.0 {
didSet {
for platforms in platformGroup {
platforms.speed = platformSpeed
}
}
}
let platformTexture = SKTexture(imageNamed: "platform")
var platformPhysics: SKPhysicsBody!
func createPlatforms() {
let platformLeft = SKSpriteNode(texture: platformTexture)
platformLeft.physicsBody = platformPhysics.copy() as? SKPhysicsBody
platformLeft.physicsBody?.isDynamic = false
platformLeft.scale(to: CGSize(width: platformLeft.size.width * 4, height: platformLeft.size.height * 4))
platformLeft.zPosition = 20
let platformRight = SKSpriteNode(texture: platformTexture)
platformRight.physicsBody = platformPhysics.copy() as? SKPhysicsBody
platformRight.physicsBody?.isDynamic = false
platformRight.scale(to: CGSize(width: platformRight.size.width * 4, height: platformRight.size.height * 4))
platformRight.zPosition = 20
let scoreNode = SKSpriteNode(color: UIColor.clear, size: CGSize(width: frame.width, height: 32))
scoreNode.physicsBody = SKPhysicsBody(rectangleOf: scoreNode.size)
scoreNode.physicsBody?.isDynamic = false
scoreNode.name = "scoreDetect"
scoreNode.zPosition = 40
platformGroup = [platformLeft, platformRight, scoreNode]
let yPosition = frame.width - platformRight.frame.width
let max = CGFloat(frame.width / 4)
let xPosition = CGFloat.random(in: -80...max)
let gapSize: CGFloat = -50
platformLeft.position = CGPoint(x: xPosition + platformLeft.size.width - gapSize, y: -yPosition)
platformRight.position = CGPoint(x: xPosition + gapSize, y: -yPosition)
scoreNode.position = CGPoint(x: frame.midX, y: yPosition - (scoreNode.size.width / 1.5))
let endPosition = frame.maxY + (platformLeft.frame.height * 3)
let moveAction = SKAction.moveBy(x: 0, y: endPosition, duration: 7)
let moveSequence = SKAction.sequence([moveAction, SKAction.removeFromParent()])
for platforms in platformGroup {
addChild(platforms)
platforms.run(moveSequence)
}
platformCount += 1
}
func loopPlatforms() {
let create = SKAction.run { [unowned self] in
self.createPlatforms()
platformCount += 1
}
let wait = SKAction.wait(forDuration: 1.1)
let sequence = SKAction.sequence([create, wait])
let repeatForever = SKAction.repeatForever(sequence)
run(repeatForever)
}
I think I can see what's going wrong. When you change platformSpeed, it changes the speed of all the platforms in platformGroup. And createPlatforms() is being called multiple times. Now, each time it's called you create a pair of platforms and assign these to platformGroup. Since you call the function multiple times, it's overwriting any existing values in the array. That's why changing platformSpeed only updates the speed of the latest platforms you've created---the older platforms stay the same speed because they're not in platformGroup anymore.
To fix this, my advice would be to have platformGroup store all the platforms currently on the screen. You could do this by changing
platformGroup = [platformLeft, platformRight, scoreNode]
to something like
let newNodes = [platformLeft, platformRight, scoreNode]
platformGroup += newNodes
// Alternatively, platformGroup.append(contentsOf: newNodes)
Now you need to make sure you're 1) only adding the new nodes to the scene, and 2) removing the old nodes from platformGroup when they're removed from the parent. You could do this by changing
let moveAction = SKAction.moveBy(x: 0, y: endPosition, duration: 7)
let moveSequence = SKAction.sequence([moveAction, SKAction.removeFromParent()])
for platforms in platformGroup {
addChild(platforms)
platforms.run(moveSequence)
}
to something like
let moveAction = SKAction.moveBy(x: 0, y: endPosition, duration: 7)
for node in newNodes {
let moveSequence = SKAction.sequence([
moveAction,
SKAction.removeFromParent(),
SKAction.run {
self.platformGroup.remove(node)
}
])
addChild(node)
node.run(moveSequence)
}
Now that you're keeping a track of all platforms ever made, your speed changes should be applied consistently to every platform on the screen. Hope this works!

Stop animation at a certain point and start up from that point again

Background
Hi all! I am creating a timer feature for my application.
Part of this timer includes a progress ring around the actual countdown numbers that fills as the timer progresses.
I added the image below in case my explanation was not easy to understand.
My goal is for the green stroke to stop animating when the "pause" button is tapped, and to continue animating when the "continue" button is tapped.
So far, the green stroke only animates correctly after pressing a start button.
To provide more background on how my code works, I added the content below.
To make the circle, I create a CAShapeLayer Object (shapeLayer) whose .path is equal to a circular UIBezierPath .CGPath
let center = view.center
let circularPath = UIBezierPath(arcCenter: center, radius: view.bounds.width/2.5, startAngle: -CGFloat.pi/2, endAngle: 2*CGFloat.pi, clockwise: true)
shapeLayer.path = circularPath.cgPath
shapeLayer.strokeColor = UIColor.systemGreen.cgColor
shapeLayer.lineCap = .round
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 7
shapeLayer.strokeEnd = 0
view.layer.addSublayer(shapeLayer)
Next, I animate the outside stroke using a CABasicAnimaton Object "basicAnimation". I do all my animation in the doAnimate() function detailed below.
func doAnimate(n: Int){
if n == 1{ //if n is 1, start from zero
basicAnimation.toValue = 0.80 // my circles start point is 0, and the endpoint at the top is .8 not 1
}//end of if
if n == 2{ //if n is 2, the timer was already started and the pause button has been pressed
//stop the animation where it is at
}//end of if
if n == 3{//if n is 3, the continue button has been pressed
// continue the animation from where it was stopped
}//end of if
basicAnimation.duration = CFTimeInterval(1500) //this is a set 25 minute timer which is why the duration is 1500
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "urSoBasic")
}//end of func
Problem
In all of my IBOUtlet button functions, I call the doAnimate function with an integer n passed in.
The purpose of my integer 'n' is to decide where the stroke should be on the progress bar.
If n == 1, the stroke will start from 0.
If n == 2, that would mean the user has pressed the pause button, and I need the stroke to stop and hold where it is.
If n == 3, then the user pressed the continue button and I need the stroke to continue again.
I cannot get my animated stroke to pause when I press the pause button.
Similarly, I cannot get it to continue when I press the continue button.
I have been researching all day for code to fix this issue, and I cannot find any.
Question
I need the stroke to stop animating and stay where it is at after pressing the pause button.
I need the stroke to continue animating if the continue button is pressed.
How do I stop an animation and then make it pick back up from that point later?
You asked:
I need the stroke to stop animating and stay where it is at after pressing the pause button.
You can use presentation() to retrieve “a copy of the presentation layer object that represents the state of the layer as it currently appears onscreen”, i.e., the state of the layer mid-animation. Use that to set the strokeEnd before you remove the animation:
if let presentationLayer = progressShapeLayer.presentation() {
progressShapeLayer.strokeEnd = presentationLayer.strokeEnd
}
progressShapeLayer.removeAnimation(forKey: animationKey)
I need the stroke to continue animating if the continue button is pressed.
Use this strokeEnd value for your fromValue when you restart the animation.
For example:
#IBDesignable
public class CircularProgressView: UIView {
#IBInspectable var duration: CFTimeInterval = 10
#IBInspectable var lineWidth: CGFloat = 1 { didSet { updatePaths() } }
private(set) var isRunning = false
private var elapsed: CFTimeInterval = 0
private var startTime: CFTimeInterval!
private let animationKey = Bundle.main.bundleIdentifier! + ".strokeEnd"
private let backgroundShapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = #colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1).cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
return shapeLayer
}()
private let progressShapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = #colorLiteral(red: 0.2319213939, green: 0.5, blue: 0.224438305, alpha: 1).cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = .round
shapeLayer.strokeEnd = 0
return shapeLayer
}()
override init(frame: CGRect = .zero) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
public override func layoutSubviews() {
super.layoutSubviews()
updatePaths()
}
public override func prepareForInterfaceBuilder() {
progressShapeLayer.strokeEnd = 1 / 3
}
}
// MARK: - Public interface
public extension CircularProgressView {
func start(duration: CFTimeInterval) {
self.duration = duration
reset()
resume()
}
func pause() {
guard
isRunning,
let presentation = progressShapeLayer.presentation()
else {
return
}
elapsed += CACurrentMediaTime() - startTime
progressShapeLayer.strokeEnd = presentation.strokeEnd
progressShapeLayer.removeAnimation(forKey: animationKey)
}
func resume() {
guard !isRunning else { return }
isRunning = true
startTime = CACurrentMediaTime()
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = elapsed / duration
animation.toValue = 1
animation.duration = duration - elapsed
animation.delegate = self
progressShapeLayer.strokeEnd = 1
progressShapeLayer.add(animation, forKey: animationKey)
}
func reset() {
isRunning = false
progressShapeLayer.removeAnimation(forKey: animationKey)
progressShapeLayer.strokeEnd = 0
elapsed = 0
}
}
extension CircularProgressView: CAAnimationDelegate {
public func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
isRunning = false
}
}
// MARK: - Utility
private extension CircularProgressView {
func configure() {
layer.addSublayer(backgroundShapeLayer)
layer.addSublayer(progressShapeLayer)
}
func updatePaths() {
let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true)
backgroundShapeLayer.lineWidth = lineWidth
progressShapeLayer.lineWidth = lineWidth
backgroundShapeLayer.path = path.cgPath
progressShapeLayer.path = path.cgPath
}
}
Yielding:

In Swift 4.2 - How can I animate a circle in SpriteKit

I am able to draw a circle at the collision point of two objects, but I want to create a nice animation for it (clockwise). I understood how to do it in single view application but I can't use the same implementation in SpriteKit. How can I do it in SpriteKit?
This is my code for adding a circle -
func drawCircleOnCollisionPoint(arrowName:String) {
let circle:SKShapeNode = SKShapeNode(circleOfRadius: 15)
circle.position = (arrowsCollidedWithTarget[arrowName]?.contactPoint)!
circle.strokeColor = SKColor.red
circle.glowWidth = 1.0
circle.fillColor = SKColor.clear
circle.zPosition = 50
self.addChild(circle)
DispatchQueue.main.asyncAfter(deadline: .now() + timeBetweenGames) {
circle.removeFromParent()
}
}
Try this:
let action = SKAction.rotate(byAngle: -360, duration: 30)
circle.run(SKAction.repeatForever(action))

How do you trigger actions with NSTimer in Spritekit?

I'm trying to get my "Player" (A circle in the middle) to increase in size once the screen is touched by running a timer.
Once the timer is over 0 seconds, it increases in size. Once the timer is over 3 seconds, it decreases to its original scale size and once the timer is over 7 seconds, it resets and this repeats forever.
What am I doing wrong?
import SpriteKit
class GameScene: SKScene {
var Center = SKSpriteNode()
var Player = SKSpriteNode()
var timer = NSTimer()
var seconds = 0
override func didMoveToView(view: SKView) {
Center = SKSpriteNode(imageNamed: "Center")
Center.size = CGSize(width: 80, height: 80)
Center.position = CGPoint(x: CGRectGetMidX(self.frame), y: CGRectGetMidY(self.frame))
self.addChild(Center)
Player = SKSpriteNode(imageNamed: "Player")
Player.size = CGSize(width: 80, height: 80)
Player.position = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
self.addChild(Player)
}
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
timer = NSTimer.scheduledTimerWithTimeInterval(4.0, target: self, selector: #selector(GameScene.playerScaleUp), userInfo: nil, repeats: true)
}
func playerScaleUp(){
if seconds > 0{
Player.runAction(SKAction.scaleBy(4, duration: 2))
}
}
func playerScaleDown(){
if seconds > 3{
Player.runAction(SKAction.scaleBy(-4, duration: 2))
}
}
func resetScale(){
if seconds > 7{
timer.invalidate()
}
}
override func update(currentTime: CFTimeInterval) {
}
}
This can be done in a few different ways:
1) Using SKActions (which my example uses)
2) Using update: method and its passed currentTime parameter
3) Using NSTimer which I wouldn't recommend because it is not affected by scene's, view's or node's paused state, so it can lead you into troubles in the future. Read more in this StackOverflow answer
4) Well, probably some more, but I will stop here.
My example uses SKAction. This means that I don't really check how much time is passed, but rather I organize actions into sequences (where actions are executed sequentially) and into groups (where actions are organized parallel). Means I use SKActions like I am playing with LEGO's :)
Here is the code ...I left debugging code intentionally, because it can help you to learn how you can use SKActions in different situations.
class GameScene: SKScene, SKPhysicsContactDelegate {
let player = SKSpriteNode(color: .blackColor(), size: CGSize(width: 50, height: 50))
var timePassed = 0.0 {
didSet {
self.label.text = String(format: "%.1f",timePassed)
}
}
let label = SKLabelNode(fontNamed: "ArialMT")
override func didMoveToView(view: SKView) {
/* Debugging - Not really needed, I added it just because of a better example */
let wait = SKAction.waitForDuration(0.1)
let updateLabel = SKAction.runBlock({[unowned self] in self.timePassed += wait.duration})
self.label.position = CGPoint(x: frame.midX, y: frame.midY+100.0)
addChild(self.label)
let timerSequence = SKAction.sequence([updateLabel, wait])
self.runAction(SKAction.repeatActionForever(timerSequence), withKey: "counting")
//This is used later, at the end of scaleUpAndDownSequence to reset the timer
let resetTimer = SKAction.runBlock({[unowned self] in self.timePassed = 0.0})
/* End Debugging */
self.player.position = CGPoint(x: frame.midX, y: frame.midY)
addChild(self.player)
let waitBeforeScaleToOriginalSize = SKAction.waitForDuration(3.0)
let waitBeforeRestart = SKAction.waitForDuration(4.0)
let scaleUp = SKAction.scaleTo(2.0, duration: 1)
let scaleDown = SKAction.scaleTo(1.0, duration: 1)
let scaleUpGroup = SKAction.group([waitBeforeScaleToOriginalSize, scaleUp])
let scaleDownGroup = SKAction.group([scaleDown, waitBeforeRestart])
//One cycle, scale up, scale down, reset timer
let scaleUpAndDownSequence = SKAction.sequence([scaleUpGroup, scaleDownGroup, resetTimer])
let loop = SKAction.repeatActionForever(scaleUpAndDownSequence)
self.player.runAction(loop, withKey: "scalingUpAndDown")
}
}
So here, I have two groups of actions:
1) scaleUpGroup
2) scaleDownGroup
scaleUpGroup group of actions has two actions in it: a scaleUp action, and an action which says how much to wait before the scale down action should occur. Because we want scaling up to happen immediately, we run it in parallel with the waitBeforeScaleToOriginalSize.
Same logic goes for scaleDownGroup. When scaleUpGroup is finished (its duration is determined by the longest action in the group) we start scaleDownGroup which scales down the player to its default size, and waits certain amount of time to repeat the whole thing.
Here is the result:
I start an animation on touch ( I've removed that code) and as you can see, scale up animation starts immediately, then after 3 seconds the player get scaled down to its original size, and after 4 seconds the whole animation repeats (player gets scaled up again etc).

Swift, sprite kit game: Have circle disappear in clockwise manner? On timer?

Alright, so I don't know the name for this but I have a sprite kit game (a runner game) that, when it's game over, is going to have a "save me" button and a timer that runs out accordingly. When the timer runs out, you can no longer click the button and save the character.
I don't want to display this timer in text however- I want a circle that "unwinds itself," if you will, and disappears at the rate that the timer runs out. I.e. when the timer reaches 0, the circle has fully disappeared. The circle disappears degree by degree in a clockwise motion in accordance with the timer.
Here are some pictures to explain what I'm talking about.
How would I do this?
By changing the path property of an SKShapeNode at a fixed interval, you can create a frame-by-frame animation sequence. To create the animation, set the path property to a sequence of shapes that starts with a circle and ends with nothing. You can use UIBezierPath, a wrapper for CGPath, to create shapes for the animation using the following steps:
Move path's "pen" to the center of the circle
Add an arc to the path with addArcWithCenter from a startAngle to endAngle
Add a line to the path from the point on the circle corresponding to the ending angle to the center
Change the endAngle by a fixed amount
Repeat steps 1-4
Here's an implementation of the above steps:
override func didMove(to:SKView) {
let circle = SKShapeNode(circleOfRadius: 50)
circle.fillColor = SKColor.blue
circle.strokeColor = SKColor.clear
circle.zRotation = CGFloat.pi / 2
addChild(circle)
countdown(circle: circle, steps: 20, duration: 5) {
print("done")
}
}
// Creates an animated countdown timer
func countdown(circle:SKShapeNode, steps:Int, duration:TimeInterval, completion:#escaping ()->Void) {
guard let path = circle.path else {
return
}
let radius = path.boundingBox.width/2
let timeInterval = duration/TimeInterval(steps)
let incr = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.run {
percent -= incr
circle.path = self.circle(radius: radius, percent:percent)
}
let wait = SKAction.wait(forDuration:timeInterval)
let action = SKAction.sequence([wait, animate])
run(SKAction.repeat(action,count:steps-1)) {
self.run(SKAction.wait(forDuration:timeInterval)) {
circle.path = nil
completion()
}
}
}
// Creates a CGPath in the shape of a pie with slices missing
func circle(radius:CGFloat, percent:CGFloat) -> CGPath {
let start:CGFloat = 0
let end = CGFloat.pi * 2 * percent
let center = CGPoint.zero
let bezierPath = UIBezierPath()
bezierPath.move(to:center)
bezierPath.addArc(withCenter:center, radius: radius, startAngle: start, endAngle: end, clockwise: true)
bezierPath.addLine(to:center)
return bezierPath.cgPath
}
and a video clip: