Desired behavior is: when an action is removed from a node (with removeAction(forKey:) for instance) it stops to animate and all the changes caused by action are discarded, so the node returns back to pervious state. In other words, I want to achieve behavior similar to CAAnimation.
But when a SKAction is removed, the node remains changed. It's not good, because to restore it's state I need to know exactly what action was removed. And if I then change the action, I also will need to update the node state restoration.
Update:
The particular purpose is to show possible move in a match-3 game. When I show a move, pieces start pulsating (scale action, repeating forever). And when the user moves I want to stop showing the move, so I remove the action. As the result, pieces may remain downscaled. Later I would like to add more fancy and complicated animations, so I want to be able to edit it easily.
Thanks to the helpful comment and answer I came to my own solution. I think the state machine would be bit too heavy here. Instead I created a wrapper node, which main purpose is run the animation. It also has a state: isAimating property. But, first of all, it allows to keep startAnimating() and stopAnimating() methods close to each other, incapsulated, so it's more difficult to mess up.
class ShowMoveAnimNode: SKNode {
let animKey = "showMove"
var isAnimating: Bool = false {
didSet {
guard oldValue != isAnimating else { return }
if isAnimating {
startAnimating()
} else {
stopAnimating()
}
}
}
private func startAnimating() {
let shortPeriod = 0.2
let scaleDown = SKAction.scale(by: 0.75, duration: shortPeriod)
let seq = SKAction.sequence([scaleDown,
scaleDown.reversed(),
scaleDown,
scaleDown.reversed(),
SKAction.wait(forDuration: shortPeriod * 6)])
let repeated = SKAction.repeatForever(seq)
run(repeated, withKey: animKey)
}
private func stopAnimating() {
removeAction(forKey: animKey)
xScale = 1
yScale = 1
}
}
Usage: just add everything that should be animated to this node. Works well with simple animations, like: fade, scale and move.
As #Knight0fDragon suggested, you would be better off using the GKStateMachine functionality, I will give you an example.
First declare the states of your player/character in your scene
lazy var playerState: GKStateMachine = GKStateMachine(states: [
Idle(scene: self),
Run(scene: self)
])
Then you need to create a class for each of these states, in this example I will show you only the Idle class
import SpriteKit
import GameplayKit
class Idle: GKState {
weak var scene: GameScene?
init(scene: SKScene) {
self.scene = scene as? GameScene
super.init()
}
override func didEnter(from previousState: GKState?) {
//Here you can make changes to your character when it enters this state, for example, change his texture.
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass is Run.Type //This is pretty obvious by the method name, which states can the character go to from this state.
}
override func update(deltaTime seconds: TimeInterval) {
//Here is the update method for this state, lets say you have a button which controls your character velocity, then you can check if the player go over a certain velocity you make it go to the Run state.
if playerVelocity > 500 { //playerVelocity is just an example of a variable to check the player velocity.
scene?.playerState.enter(Run.self)
}
}
}
Now of course in your scene you need to do two things, first is initialize the character to a certain state or else it will remain stateless, so you can to this in the didMove method.
override func didMove(to view: SKView) {
playerState.enter(Idle.self)
}
And last but no least is make sure the scene update method calls the state update method.
override func update(_ currentTime: TimeInterval) {
playerState.update(deltaTime: currentTime)
}
Related
I am making a word game. For this I am using SceneKit and adding a SpriteNodes to represent letter tiles.
The idea is that when a user clicks on a letter tile, some extra tiles appear around it with different letter options. My issue is regarding the touch gestures for various interactions.
When a user taps on a letter tile, additional tiles are shown. I have achieved this using the following method in my tile SpriteNode class:
override func touchesBegan(_ touches:Set<UITouch> , with event: UIEvent?) {
guard let touch = touches.first else {
return
}
delegate?.updateLetter(row: row, column: column, x:xcoord, y:ycoord, useCase: 1)
}
This triggers the delegate correctly which shows another sprite node.
What I would like to achieve is for a long press to remove the sprite node from parent. I have found the .removeFromParent() method, however I cannot get this to detect a long press gesture.
My understanding is that this type of gesture must be added using UIGestureRecognizer. I can add the following method to my Scene class:
override func didMove(to view: SKView) {
let longPress = UILongPressGestureRecognizer(target: self,
action: #selector(GameScene.longPress(sender:)))
view.addGestureRecognizer(longPress)
}
#objc func longPress(sender: UILongPressGestureRecognizer) {
print("Long Press")
This will detect a long press anywhere on the scene. However I need to be able to handle the pressed nodes properties before removing it. I have tried adding the below to the longPress function:
let location = sender.location(in: self)
let touchedNodes = nodes(at: location)
let firstTouchedNode = atPoint(location).name
touchedNodes[0].removeFromParent()
but I get the following error: Cannot convert value of type 'GameScene' to expected argument type 'UIView?'
This seems a little bit of a messy way of doing things, as I have touch methods in different places.
So my question is, how can I keep the current touchesBegan method that is in the tile class, and add a long press gesture to be able to reference and delete the spriteNode?
Long press gestures are continuous gestures that may be called multiple times as you are seeing. Have you tried Recognizer.State.began, .changed, .ended? I solved a similar problem doing things this way.
EDIT - I think one way to get there is to get your object on handleTap and hang on to the object. Then when LongPress happens, you already have your node. If something changes before longPress, obviously you need to reset. Sorry, this is some extra code on here, but look at hitTest.
#objc func handleTap(recognizer: UITapGestureRecognizer)
{
let location: CGPoint = recognizer.location(in: gameScene)
if(data.isAirStrikeModeOn == true)
{
let projectedPoint = gameScene.projectPoint(SCNVector3(0, 0, 0))
let scenePoint = gameScene.unprojectPoint(SCNVector3(location.x, location.y, CGFloat(projectedPoint.z)))
gameControl.airStrike(position: scenePoint)
}
else
{
let hitResults = gameScene.hitTest(location, options: hitTestOptions)
for vHit in hitResults
{
if(vHit.node.name?.prefix(5) == "Panel")
{
// May have selected an invalid panel or auto upgrade was on
if(gameControl.selectPanel(vPanel: vHit.node.name!) == false) { return }
return
}
}
}
}
So I am not completely satisfied with this answer, however it is a work around for what I need.
What I have done is added two variables ‘touchesStart’ and ‘touchesEnd’ to my tiles class.
Then in touchesBegan() I add a call to update touchesStart with CACurrentMediaTime() and update touchesEnd via the touchesEnded() function.
Then in the touchesEnded() I subtract touchesStart from touchesEnd. If the difference is more than 1.0 I call the function for long press. If less than 1.0 I call the function for tap.
Let me preface this question by saying I know it has been asked a few times, but the most upvoted post here was from back in 2014 and the top answer doesn't seem to work anymore.
Issue
In my game, I have all my 'moving' nodes as children of pauseNode. When the pauses button is pressed or when the player leaves the app, pauseScene() is called:
func pauseScene() {
self.node.speed = 0
self.physicsWorld.speed = 0
self.node.isPaused = true
}
This pauses all sprite nodes and emitter nodes. This keeps the sprite nodes paused if the app transitions from background to foreground state, but the particle emitters seem to resume animating.
Current solution
I solved this issue by triggering pauseScene() after a small delay when the app became active.
override func didMove(to view: SKView) {
NotificationCenter.default.addObserver(self, selector: #selector(GameScene.applicationDidBecomeActive(notification:)), name: NSNotification.Name.UIApplicationDidBecomeActive, object: app)
}
#objc func applicationDidBecomeActive(notification: NSNotification) {
NSDelay(0.01) {
pauseScene()
}
}
func NSDelay(_ delay:Double, closure:#escaping ()->()) {
let when = DispatchTime.now() + delay
DispatchQueue.main.asyncAfter(deadline: when, execute: closure)
}
Goal
While this solution pauses the particles, it is not perfect because they are visible moving for the split second it takes to pause them again (this is the shortest delay that actually re-pauses them).
So, after reading the post I linked above, I tried to override applicationDidBecomeActive() in an SKView subclass as was suggested, but I couldn't get the method to be called. I want to prevent these particles from being unpaused in the first place, if possible. Thanks
So, it's been a little over a day and I've found a better solution. A comment in this post suggested having a variable that recorded the status of the scene, and updating it along with the pause/play functions:
var sceneIsPaused = false
func pauseScene() {
self.node.speed = 0
self.physicsWorld.speed = 0
self.node.isPaused = true
sceneIsPaused = true
}
func playScene() {
self.node.speed = 1
self.physicsWorld.speed = 1
self.node.isPaused = false
sceneIsPaused = false
}
Then, override SKView's update method and check the state of that variable. If the scene should be paused, pause it. This will mean that if the scene is automatically unpaused it will re-pause the next frame. This is much faster and cleaner than setting a delay:
override func update(_ currentTime: TimeInterval) {
if (sceneIsPaused == true) {
self.node.speed = 0
self.physicsWorld.speed = 0
self.node.isPaused = true
}
}
I'm using UIViewPropertyAnimator to run an array interactive animations, and one issue I'm having is that whenever the I reverse the animations I can't run the animations back forward again.
I'm using three functions to handle the animations in conjunction with a pan gesture recognizer.
private var runningAnimations = [UIViewPropertyAnimator]()
private func startInteractiveTransition(gestureRecognizer: UIPanGestureRecognizer, state: ForegroundState, duration: TimeInterval) {
if runningAnimations.isEmpty {
animateTransitionIfNeeded(gestureRecognizer: gestureRecognizer, state: state, duration: duration)
}
for animator in runningAnimations {
animator.pauseAnimation()
animationProgressWhenInterrupted = animator.fractionComplete
}
}
private func animateTransitionIfNeeded(gestureRecognizer: UIPanGestureRecognizer, state: ForegroundState, duration: TimeInterval) {
guard runningAnimations.isEmpty else {
return
}
let frameAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1) {
switch state {
case .expanded:
// change frame
case .collapsed:
// change frame
}
}
frameAnimator.isReversed = false
frameAnimator.addCompletion { _ in
print("remove all animations")
self.runningAnimations.removeAll()
}
self.runningAnimations.append(frameAnimator)
for animator in runningAnimations {
animator.startAnimation()
}
}
private func updateInteractiveTransition(gestureRecognizer: UIPanGestureRecognizer, fractionComplete: CGFloat) {
if runningAnimations.isEmpty {
print("empty")
}
for animator in runningAnimations {
animator.fractionComplete = fractionComplete + animationProgressWhenInterrupted
}
}
What I've noticed is after I reverse the animations and then call animateTransitionIfNeeded, frameAnimator is appended to running animations however when I call updateInteractiveTransition immediately after and check runningAnimations, it's empty.
So I'm led to believe that this may have to do with how swift handles memory possibly or how UIViewAnimating completes animations.
Any suggestions?
I've come to realize the issue I was having the result of how UIViewPropertyAnimator handles layout constraints upon reversal.
I couldn't find much detail on it online or in the official documentation, but I did find this which helped a lot.
Animator just animates views into new frames. However, reversed or not, the new constraints still hold regardless of whether you reversed the animator or not. Therefore after the animator finishes, if later autolayout again lays out views, I would expect the views to go into places set by currently active constraints. Simply said: The animator animates frame changes, but not constraints themselves. That means reversing animator reverses frames, but it does not reverse constraints - as soon as autolayout does another layout cycle, they will be again applied.
Like normal you set your constraints and call view.layoutIfNeeded()
animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1) {
[unowned self] in
switch state {
case .expanded:
self.constraintA.isActive = false
self.constraintB.isActive = true
self.view.layoutIfNeeded()
case .collapsed:
self.constraintB.isActive = false
self.constraintA.isActive = true
self.view.layoutIfNeeded()
}
}
And now, since our animator has the ability to reverse, we add a completion handler to ensure that the correct constraints are active upon completion by using the finishing position.
animator.addCompletion { [weak self] (position) in
if position == .start {
switch state {
case .collapsed:
self?.constraintA.isActive = false
self?.constraintB.isActive = true
self?.view.layoutIfNeeded()
case .expanded:
self?.constraintA.isActive = false
self?.constraintB.isActive = true
self?.view.layoutIfNeeded()
}
}
}
The animator operates on animatable properties of views, such as the frame, center, alpha, and transform properties, creating the needed animations from the blocks you provide.
This is the crucial part of the documentation.
You can properly animate:
frame, center, alpha and transform, so you would not be able to animate properly NSConstraints.
You should modify frames of views inside of addAnimations block
I'm making a card game with Swift 4 and SpriteKit. I made a custom subclass of SKScene, and inside this class is a SKNode which I'm using as a layer (gameLayer), to which I attach all Nodes, which shall be stopped.
I also have made a button, which calls a function (pauseGame) that toggles isPaused of the gameLayer, which works perfectly.
The problem is: I want that when the game moves to the scene, it starts already stopped, the user shall begin the game by pressing a button. But when I call the pauseGame function inside the didMove function, it doesn't work, the gameLayer remains active.
I made some observations putting ' print (gameLayer.isPaused) ' and frame count inside the update function. It showed that actually the scene starts with gameLayer.isPaused set to true inside the didMove as it should be, but after the 2nd frame it gets set to false. I have really no idea where it can happen, as I don't appeal to gameLayer.isPaused anywhere else in code.
Of course a solution would be to call pauseGame after the 2nd frame, but I think its not a clean way.
class BMScene: SKScene {
let gameLayer = SKNode()
func pauseGame(){
if gameLayer.isPaused {
gameLayer.isPaused = false
} else {
gameLayer.isPaused = true
}
}
override func didMove(to view: SKView) {
self.pauseGame()
}
isPaused is a bugged up mess in the world of SpriteKit. I have literally argued with developers at apple about the broken nature of it, and they claim that is how it is intended to work, and we must deal with it. The latest atrocity I know about, is that isPaused is defaulted to true for new scenes, then a message is sent out to unpause it. On top of that, when you unpause a parent node, it also unpauses all children nodes.
So in your code here, override the isPaused of your scene to be able to capture your pausing event to block it from going to its children
class BMScene: SKScene {
let gameLayer = SKNode()
private var _paused : Boolean = false
override var isPaused : Boolean
{
get
{
return _paused
}
set
{
_paused = newValue
}
}
func pauseGame(_ state: Boolean? = null){
gameLayer.isPaused = state ?? !gameLayer.isPaused
}
override func didMove(to view: SKView) {
self.pauseGame(true)
}
}
This will stop your node from changing its childrens pause state. After this moment, you should have complete control of your gameLayer pause state, and it should flow how you think it is flowing.
I have also modified your function to allow you to force the pause state, as well as toggle when you want to swap the state.
E.g.
Pause:
pauseGame(true)
'gameLayer.isPaused is now true
Unpause:
pauseGame(true)
'gameLayer.isPaused is now false
Toggle:
pauseGame()
'gameLayer goes from false to true, or true to false
?? Means COALESCE, so if state is null, it will move along the chain to the next value, which is !gameLayer.isPaused
I've solved similar problem by setting speed to zero:
gameLayer.speed = 0
// This will, obviously, unpause
gameLayer.speed = 1
I have a rock sprite that it is falling and every time it goes out of the screen I want to reset it back to the top and have it fall again. It should be a continuous cycle. Here's my code:
import SpriteKit
import GameplayKit
class GameScene: SKScene {
func addRock(){
var rock = self.childNode(withName: "rock")
rock?.physicsBody?.affectedByGravity = true
//self.addChild(rock!)
}
override func sceneDidLoad() {
//bRock = self.childNode(withName: "rock")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
//addRock()
var rock = self.childNode(withName: "rock")
rock?.physicsBody?.affectedByGravity = true
/*if (Int((rock?.position.y)!) < Int((self.view?.scene?.view?.bounds.minY)!)){
print("out of screen")
rock?.removeFromParent()
addRock()
}*/
}
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
var rock = self.childNode(withName: "rock")
//rock?.physicsBody?.affectedByGravity = true
if (!intersects(rock!)){
print("out of screen")
rock?.removeFromParent()
addRock()
}
}
}
I have the rock coming on the screen and then falling. Once it leaves the screen, it does not reset and I get an error. I tried placing the reset code in both the touchesBegan and update functions but neither work. If someone could guide me to the correct path, that would be greatly appreciated.
The problem you're having is that you're removing the rock node from the scene but the addRock method doesn't actually add a new rock, it just finds the existing node if there is one and sets a property on its physicsBody. Instead of removing the node, you should just change its position.
Assuming your scene has the default anchor point of (0,0), you can reset the position like this:
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
if let rock = self.childNode(withName: "rock") {
if !intersects(rock!) {
print("out of screen")
rock.position.y = size.height/2 // divided by 2 as discussed in comments
}
}
That will reset the rock's position to the top of the scene and it should resume falling from there.
Three ways:
the same rock is newly located at the right place after leaving the screen, so you just need to set the position of it in (misnamed) addRock (resetRock should be better): rock.position = CGPoint(...). But beware that you also need to reset its velocity, etc, as it was previously moved by physical laws...
create a new rock each time one leaves the screen in addRock: let rock = SKSpriteNode(...)... (and all the initializing code for it. Much simpler as all its physical parameters will be initialized with right values by default.
create a clone of an initial rock. I suppose you have a model of it in your sks file, then don't name it rock but something like (rockModel), and just clone it in addRock with giving it the right name rock. Don't forget to remove the model from the scene at the beginning. This is the usual way to do it.
You could also think about using a series of move actions in a sequence to control the rock's behaviour. Given the move actions are already defined, in your defined sequence, pass in sequence[(falling, hide, backtotop, unhide)] - Repeat Forever.
As long as the rocks don't actually interact with anything and are just for the backdrop/background, hiding and unhiding them is a good way to give the affect they are falling.