Question

I want to create my own progress bar in Sprite Kit.
I figured I will need to images - one fully empty progress bar and filled progress bar.
I have those images, I put filled one on top of empty one, they are regular SKSPriteNodes now I can't figure out how do I cut my filled image where I need?

How do I cut SKSpriteNode image at certain point? Maybe texture?

Was it helpful?

Solution

I would recommend looking into SKCropNode. For a visual aid how SKCropNode works, look it up in the Apple Programming Guide. I have read through the entire document multiple times and it is a particularly good read.

SKCropNode is basically an SKNode which you add to your scene, but its children can be cropped by a mask. This mask is set in the maskNode property of the SKCropNode. In this way, you only need one texture image. I would subclass SKCropNode to implement functionality to move or resize the mask, so you can easily update its appearance.

@interface CustomProgressBar : SKCropNode

/// Set to a value between 0.0 and 1.0.
- (void) setProgress:(CGFloat) progress;

@end

@implementation CustomProgressBar

- (id)init {
  if (self = [super init]) {
    self.maskNode = [SKSpriteNode spriteNodeWithColor:[SKColor whiteColor] size:CGSizeMake(300,20)];
    SKSpriteNode *sprite = [SKSpriteNode spriteNodeWithImageNamed:@"progressBarImage"];
    [self addChild:sprite];
  }
  return self;
}

- (void) setProgress:(CGFloat) progress {
  self.maskNode.xScale = progress;
}

@end

In your scene:

#import "CustomProgressBar.h"

// ...

CustomProgressBar * progressBar = [CustomProgressBar new];
[self addChild:progressBar];

// ...

[progressBar setProgress:0.3];

// ...

[progressBar setProgress:0.7];

Note: this code doesn't move the mask (so the sprite will be cropped on either side) but I'm sure you get the idea.

OTHER TIPS

Quite simply: you need a frame image (optional) and a "bar" image. The bar image out to be a single, solid color and as high as you need it and 1 or 2 pixels wide. A SKShapeNode as bar will do as well.

Just making the bar and animating is simply a matter of changing the SKSpriteNode's size property. For example to make the bar represent progress between 0 and 100 just do:

sprite.size = CGSizeMake(progressValue, sprite.size.height);

Update the size whenever progressValue changes.

You'll notice the image will increase in width to both left and right, to make it stretch only to the right change the anchorPoint to left-align the image:

sprite.anchorPoint = CGPointMake(0.0, 0.5);

That is all. Draw a frame sprite around it to make it look nicer.

that is my ProgressBar in swift :

import Foundation

import SpriteKit


class IMProgressBar : SKNode{

var emptySprite : SKSpriteNode? = nil
var progressBar : SKCropNode
init(emptyImageName: String!,filledImageName : String)
{
    progressBar = SKCropNode()
    super.init()
    let filledImage  = SKSpriteNode(imageNamed: filledImageName)
    progressBar.addChild(filledImage)
    progressBar.maskNode = SKSpriteNode(color: UIColor.whiteColor(),
        size: CGSize(width: filledImage.size.width * 2, height: filledImage.size.height * 2))

    progressBar.maskNode?.position = CGPoint(x: -filledImage.size.width / 2,y: -filledImage.size.height / 2)
    progressBar.zPosition = 0.1
    self.addChild(progressBar)

    if emptyImageName != nil{
        emptySprite = SKSpriteNode.init(imageNamed: emptyImageName)
        self.addChild(emptySprite!)
    }
}
func setXProgress(xProgress : CGFloat){
    var value = xProgress
    if xProgress < 0{
        value = 0
    }
    if xProgress > 1 {
        value = 1
    }
    progressBar.maskNode?.xScale = value
}

func setYProgress(yProgress : CGFloat){
    var value = yProgress
    if yProgress < 0{
        value = 0
    }
    if yProgress > 1 {
        value = 1
    }
    progressBar.maskNode?.yScale = value
}
required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

}

//How to use :

let progressBar = IMProgressBar(emptyImageName: "emptyImage",filledImageName: "filledImage")

or

let progressBar = IMProgressBar(emptyImageName: nil,filledImageName: "filledImage")

and add this progressBar to any SKNode :

self.addChild(progressBar)

//That's all.

Assuming HealthBarNode is a subclass of SKSpriteNode with a public property health that varies between 0.0 and 1.0 and whose parental property texture is generated from the entire color bar image of width _textureWidth (a private property), you could do something like this:

- (void)setHealth:(CGFloat)fraction
{
    self.health = MIN(MAX(0.0, fraction), 1.0); // clamp health between 0.0 and 1.0
    SKTexture *textureFrac = [SKTexture textureWithRect:CGRectMake(0, 0, fraction, 1.0) inTexture:self.texture]; 
// check docs to understand why you can pass in self.texture as the last parameter every time

    self.size = CGSizeMake(fraction * _textureWidth, self.size.height);
    self.texture = textureFrac;
}

Setting the health to a new value will cause the health bar (added as a child to the main scene, say) to get cropped properly.

I built a small library to deal with this exact scenario! Here is SpriteBar: https://github.com/henryeverett/SpriteBar

Swift 4:

( my answer 1 -> make a rapid and simple progress bar)

To make a simple progress bar based to colors you can subclass a simple SKNode without using SKCropNode:

class SKProgressBar: SKNode {
    var baseSprite: SKSpriteNode!
    var coverSprite: SKSpriteNode!
    override init() {
        super.init()
    }
    convenience init(baseColor: SKColor, coverColor: SKColor, size: CGSize ) {
        self.init()
        self.baseSprite = SKSpriteNode(color: baseColor, size: size)
        self.coverSprite = SKSpriteNode(color: coverColor, size: size)
        self.addChild(baseSprite)
        self.addChild(coverSprite)
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func setProgress(_ value:CGFloat) {
        print("Set progress bar to: \(value)")
        guard 0.0 ... 1.0 ~= value else { return }
        let originalSize = self.baseSprite.size
        var calculateFraction:CGFloat = 0.0
        self.coverSprite.position = self.baseSprite.position
        if value == 0.0 {
            calculateFraction = originalSize.width
        } else if 0.01..<1.0 ~= value {
            calculateFraction = originalSize.width - (originalSize.width * value)
        }
        self.coverSprite.size = CGSize(width: originalSize.width-calculateFraction, height: originalSize.height)
        if value>0.0 && value<1.0 {
            self.coverSprite.position = CGPoint(x:(self.coverSprite.position.x-calculateFraction)/2,y:self.coverSprite.position.y)
        }
    }
}

Usage:

self.energyProgressBar = SKProgressBar.init(baseColor: .white, coverColor: .blue, size: CGSize(width:200,height:50))
addChild(self.energyProgressBar)
// other code to see progress changing..
let wait = SKAction.wait(forDuration: 2.0)
let action1 = SKAction.run {
    self.energyProgressBar.setProgress(0.7)
}
let action2 = SKAction.run {
    self.energyProgressBar.setProgress(0.0)
}
let action3 = SKAction.run {
    self.energyProgressBar.setProgress(1.0)
}
let action4 = SKAction.run {
    self.energyProgressBar.setProgress(0.5)
}
let action5 = SKAction.run {
    self.energyProgressBar.setProgress(0.1)
}
let sequence = SKAction.sequence([wait,action1,wait,action2,wait,action3,wait,action4,wait,action5])
self.run(sequence)

Output:

enter image description here

There is no "cutting" an image/texture.

An alternative to what Cocos offered is to make a couple of textures and interchange them into your node depending on health. I did a game where the health bar changed texture every 10 points (range was 0-100). After some trial and error though, I just ended up doing what Cocos already suggested.

enter image description here

I did it like this, and it works perfectly.

So first, I declared a SKSpriteNode:

baseBar = [SKSpriteNode spriteNodeWithColor:[UIColor redColor] size:CGSizeMake(CGRectGetMidX(self.frame)-40, self.frame.size.height/10)];
//The following will make the health bar to reduce from right to left
//Change it to (1,0.5) if you want to have it the other way
//But you'd have to play with the positioning as well
[baseBar setAnchorPoint:CGPointMake(0, 0.5)];
CGFloat goodWidth, goodHeight;
goodHeight =self.frame.size.height-(baseBar.frame.size.height*2/3);
goodWidth =self.frame.size.width-(10 +baseBar.frame.size.width);
[baseBar setPosition:CGPointMake(goodWidth, goodHeight)];
[self addChild:baseBar];

I then added a 'Frame' for the bar, with an SKShapeNode, without fill colour (clearcolour), and a stroke colour:

//The following was so useful
SKShapeNode *edges = [SKShapeNode shapeNodeWithRect:baseBar.frame];
edges.fillColor = [UIColor clearColor];
edges.strokeColor = [UIColor blackColor];
[self addChild:edges];

When I wanted to reduce the health, I did the following:

    if (playerHealthRatio>0) {
        playerHealthRatio -= 1;
        CGFloat ratio = playerHealthRatio / OriginalPlayerHealth;
        CGFloat newWidth =baseBar.frame.size.width*ratio;
        NSLog(@"Ratio: %f newwidth: %f",ratio,newWidth);
        [baseBar runAction:[SKAction resizeToWidth:newWidth duration:0.5]];
    }else{
//        NSLog(@"Game over");
    }

Simple, clean and not complicated at all.

Swift 4

( my answer 3 -> old SpriteBar project fully translated to swift)

To make a progress bar based to SKTextureAtlas you can use the Objective C project called SpriteBar maded by Henry Everett.

I've forked and fully translated this project, this is the source:

class SpriteBar: SKSpriteNode {
    var textureReference = ""
    var atlas: SKTextureAtlas!
    var availableTextureAddresses = Array<Int>()
    var timer = Timer()
    var timerInterval = TimeInterval()
    var currentTime = TimeInterval()
    var timerTarget: AnyObject!
    var timerSelector: Selector!

    init() {
        let defaultAtlas = SKTextureAtlas(named: "sb_default")
        let firstTxt = defaultAtlas.textureNames[0].replacingOccurrences(of: "@2x", with: "")
        let texture = defaultAtlas.textureNamed(firstTxt)
        super.init(texture: texture, color: .clear, size: texture.size())
        self.atlas = defaultAtlas
        commonInit()
    }
    convenience init(textureAtlas: SKTextureAtlas?) {
        self.init()
        self.atlas = textureAtlas
        commonInit()
    }
    func commonInit() {
        self.textureReference = "progress"
        resetProgress()
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func closestAvailableToPercent(_ percent:Int)->Int {
        var closest = 0
        for thisPerc in self.availableTextureAddresses {
            if labs(Int(thisPerc) - percent) < labs(closest - percent) {
                closest = Int(thisPerc)
            }
        }
        return closest
    }
    func percentFromTextureName(_ string:String) -> Int? {
        let clippedString = string.replacingOccurrences(of: "@2x", with: "")
        let pattern = "(?<=\(textureReference)_)([0-9]+)(?=.png)"
        let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
        let matches = regex?.matches(in: clippedString, options: [], range: NSRange(location: 0, length: clippedString.count))
        // If the matches don't equal 1, you have done something wrong.
        if matches?.count != 1 {
            NSException(name: NSExceptionName(rawValue: String("SpriteBar: Incorrect texture naming.")), reason: "Textures should follow naming convention: \(textureReference)_#.png. Failed texture name: \(string)", userInfo: nil).raise()
        }
        for match: NSTextCheckingResult? in matches ?? [NSTextCheckingResult?]() {
            let matchRange = match?.range(at: 1)
            let range = Range(matchRange!, in: clippedString)!
            return Int(clippedString[range.lowerBound..<range.upperBound])
        }
        return nil
    }

    func resetProgress() {
        self.texture = self.atlas.textureNamed("\(self.textureReference)_\(closestAvailableToPercent(0)).png")
        self.availableTextureAddresses = []
        for name in self.atlas.textureNames {
            self.availableTextureAddresses.append(self.percentFromTextureName(name)!)
        }
        self.invalidateTimer()
        self.currentTime = 0
    }
    func setProgress(_ progress:CGFloat) {
        // Set texure
        let percent: CGFloat = CGFloat(lrint(Double(progress * 100)))
        let name = "\(textureReference)_\(self.closestAvailableToPercent(Int(percent))).png"
        self.texture = self.atlas.textureNamed(name)
        // If we have reached 100%, invalidate the timer and perform selector on passed in object.
        if fabsf(Float(progress)) >= fabsf(1.0) {
            if timerTarget != nil && timerTarget.responds(to: timerSelector) {
                typealias MyTimerFunc = @convention(c) (AnyObject, Selector) -> Void
                let imp: IMP = timerTarget.method(for: timerSelector)
                let newImplementation = unsafeBitCast(imp, to: MyTimerFunc.self)
                newImplementation(self.timerTarget, self.timerSelector)
            }
            timer.invalidate()
        }
    }
    func setProgressWithValue(_ progress:CGFloat, ofTotal maxValue:CGFloat) {
        self.setProgress(progress/maxValue)
    }

    func numberOfFrames(inAnimation animationName: String) -> Int {
        // Get the number of frames in the animation.
        let allAnimationNames = atlas.textureNames
        let nameFilter = NSPredicate(format: "SELF CONTAINS[cd] %@", animationName)
        return ((allAnimationNames as NSArray).filtered(using: nameFilter)).count
    }
    func startBarProgress(withTimer seconds: TimeInterval, target: Any?, selector: Selector) {
        resetProgress()
        timerTarget = target as AnyObject
        timerSelector = selector
        // Split the progress time between animation frames
        timerInterval = seconds / TimeInterval((numberOfFrames(inAnimation: textureReference) - 1))
        timer = Timer.scheduledTimer(timeInterval: timerInterval, target: self, selector: #selector(self.timerTick(_:)), userInfo: seconds, repeats: true)
    }
    @objc func timerTick(_ timer: Timer) {
        // Increment timer interval counter
        currentTime += timerInterval
        // Make sure we don't exceed the total time
        if currentTime <= timer.userInfo as! Double {
            setProgressWithValue(CGFloat(currentTime), ofTotal: timer.userInfo as! CGFloat)
        }
    }
    func invalidateTimer() {
        timer.invalidate()
    }
}

Usage:

let progressBarAtlas = SKTextureAtlas.init(named: "sb_default")
self.energyProgressBar = SpriteBar(textureAtlas: progressBarAtlas)
self.addChild(self.energyProgressBar)
self.energyProgressBar.size = CGSize(width:350, height:150)
self.energyProgressBar.position = CGPoint(x:self.frame.width/2, y:self.frame.height/2)

let wait = SKAction.wait(forDuration: 2.0)
let action1 = SKAction.run {
    self.energyProgressBar.setProgress(0.7)
}
let action2 = SKAction.run {
    self.energyProgressBar.setProgress(0.0)
}
let action3 = SKAction.run {
    self.energyProgressBar.setProgress(1.0)
}
let action4 = SKAction.run {
    self.energyProgressBar.setProgress(0.5)
}
let action5 = SKAction.run {
    self.energyProgressBar.setProgress(0.1)
}
let action6 = SKAction.run {
    self.energyProgressBar.startBarProgress(withTimer: 10, target: self, selector: #selector(self.timeOver))
}
let sequence = SKAction.sequence([wait,action1,wait,action2,wait,action3,wait,action4,wait,action5,wait,action6])
self.run(sequence)

To have more details you can find my GitHUB repo here

Swift 4:

( my answer 2 -> make a complex progress bar using textures)

To make a complex progress bar based to texture and colors you can subclass a simple SKNode. About this case, SpriteKit for now (swift v4.1.2) doesn't have a method to directly cutting a SKTexture. We need to use another method called texture(from:crop:)

class SKProgressImageBar: SKNode {
    var baseSprite: SKSpriteNode!
    var coverSprite: SKSpriteNode!
    var originalCoverSprite: SKSpriteNode!
    override init() {
        super.init()
    }
    convenience init(baseImageName:String="", coverImageName:String="", baseColor: SKColor, coverColor: SKColor, size: CGSize ) {
        self.init()
        self.baseSprite = baseImageName.isEmpty ? SKSpriteNode(color: baseColor, size: size) : SKSpriteNode(texture: SKTexture(imageNamed:baseImageName), size: size)
        self.coverSprite = coverImageName.isEmpty ? SKSpriteNode(color: coverColor, size: size) : SKSpriteNode(texture: SKTexture(imageNamed:coverImageName), size: size)
        self.originalCoverSprite = self.coverSprite.copy() as! SKSpriteNode
        self.addChild(baseSprite)
        self.addChild(coverSprite)
        self.coverSprite.zPosition = 2.0
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func setProgress(_ value:CGFloat) {
        print("Set progress bar to: \(value)")
        guard 0.0 ... 1.0 ~= value else { return }
        self.coverSprite.texture = self.originalCoverSprite.texture
        let originalSize = self.baseSprite.size
        var calculateFraction:CGFloat = 0.0
        self.coverSprite.position = self.baseSprite.position
        if value == 1.0 {
            calculateFraction = originalSize.width
        } else if 0.01..<1.0 ~= value {
            calculateFraction = originalSize.width * value
        }

        let coverRect = CGRect(origin: self.baseSprite.frame.origin, size: CGSize(width:calculateFraction,height:self.baseSprite.size.height))
        if let parent = self.parent, parent is SKScene, let parentView = (parent as! SKScene).view {
            if let texture = parentView.texture(from: self.originalCoverSprite, crop: coverRect) {
                let sprite = SKSpriteNode(texture:texture)
                self.coverSprite.texture = sprite.texture
                self.coverSprite.size = sprite.size
            }
            if value == 0.0 {
                self.coverSprite.texture = SKTexture()
                self.coverSprite.size = CGSize.zero
            }
            if value>0.0 && value<1.0 {
                let calculateFractionForPosition = originalSize.width - (originalSize.width * value)
                self.coverSprite.position = CGPoint(x:(self.coverSprite.position.x-calculateFractionForPosition)/2,y:self.coverSprite.position.y)
            }
        }
    }
}

Usage:

some texture just to make an example:

baseTxt.jpeg:

enter image description here

coverTxt.png:

enter image description here

Code:

self.energyProgressBar = SKProgressImageBar.init(baseImageName:"baseTxt.jpeg", coverImageName: "coverTxt.png", baseColor: .white, coverColor: .blue, size: CGSize(width:200,height:50))
//self.energyProgressBar = SKProgressImageBar.init(baseColor: .white, coverColor: .blue, size: CGSize(width:200,height:50))
self.addChild(self.energyProgressBar)
self.energyProgressBar.position = CGPoint(x:self.frame.width/2, y:self.frame.height/2)
let wait = SKAction.wait(forDuration: 2.0)
let action1 = SKAction.run {
    self.energyProgressBar.setProgress(0.7)
}
let action2 = SKAction.run {
    self.energyProgressBar.setProgress(0.0)
}
let action3 = SKAction.run {
    self.energyProgressBar.setProgress(1.0)
}
let action4 = SKAction.run {
    self.energyProgressBar.setProgress(0.5)
}
let action5 = SKAction.run {
    self.energyProgressBar.setProgress(0.1)
}
let sequence = SKAction.sequence([wait,action1,wait,action2,wait,action3,wait,action4,wait,action5])
self.run(sequence)

Output:

with colors:

enter image description here

with textures:

enter image description here

A simple class using two sprite nodes

class PBProgressBar: SKNode {
private var baseNode : SKSpriteNode!
private var progressNode : SKSpriteNode!
private var basePosition: CGPoint!
var progress: CGFloat!
init(progress: CGFloat = 0.45, position: CGPoint = CGPoint.zero) {
    super.init()
    self.progress = progress
    self.basePosition = position
    configureProgress()
}
required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}
func configureProgress() {
    baseNode = SKSpriteNode(color: .white, size: CGSize(width: 10, height: 100))
    baseNode.anchorPoint = CGPoint.zero
    let heightFraction = baseNode.size.height * progress
    baseNode.position = basePosition
    progressNode = SKSpriteNode(color: .blue, size: CGSize(width: 10, height: heightFraction))
    progressNode.anchorPoint = CGPoint.zero
    baseNode.addChild(progressNode)
    self.addChild(baseNode)
}

}

My attempt, basing myself on Swift Goose tutorial:

import SpriteKit

final class Bar: SKNode {

// MARK: - Properties
    
private let maxProgress = CGFloat(1.0)
private var maxBarWidth = CGFloat(0)

private let cropNode = SKCropNode()
private let bar = SKSpriteNode(texture: SKTexture(imageNamed: "Bar"))
private let barMask = SKSpriteNode(texture: SKTexture(imageNamed: "BarContainer"))
private let barContainer = SKSpriteNode(texture: SKTexture(imageNamed: "BarContainer"))
    
// MARK: - Configuration

func configure(on frame: CGRect, barProgressPercent: CGFloat) {
    let size = CGSize(width: 150, height: 45) // change size if needed
    let offset = CGFloat(4)
    maxBarWidth = size.width - offset
        
    barContainer.size.width = size.width
    barContainer.size.height = size.height
    barContainer.position = CGPoint(x: frame.width/2, y: frame.height/2)
    barContainer.zPosition = 1
    addChild(barContainer)
    
    bar.size.width = maxBarWidth * barProgressPercent
    bar.size.height = size.height - offset
    bar.position.x = -maxBarWidth / 2.0
    bar.anchorPoint = CGPoint(x: 0, y: 0.5)
    
    barMask.size.width = maxBarWidth
    barMask.size.height = size.height - offset
    
    cropNode.maskNode = barMask
    cropNode.position = CGPoint(x: frame.width/2, y: frame.height/2)
    cropNode.addChild(bar)
    cropNode.zPosition = 2
    addChild(cropNode)
}

// MARK: - Methods

func update(with progress: CGFloat, duration: TimeInterval = 0.3, completion: (() -> Void)? = nil) {
    bar.run(SKAction.resize(toWidth: CGFloat(progress/maxProgress)*maxBarWidth, duration: duration)) {
        completion?()
    }
}
}

Usage:

let bar = Bar()
bar.configure(on: /*view frame here*/, barProgressPercent: /*your initial percentage here*/)

bar.update(with: /*your new percentage*/, duration: /*your custom duration*/) {
        // do stuff after bar animation is completed
    }

Note that barProgressPercent must be a value from 0.0 to 1.0.

Result.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top